From 3c527c474977995b73870c153c278a0835226ef6 Mon Sep 17 00:00:00 2001 From: Eyal Date: Sun, 22 Feb 2026 06:41:05 +0200 Subject: [PATCH 01/35] New library UI: floating pill nav bar, Keep Reading panel, pill chips, color pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Floating pill-shaped bottom nav bar (mobile) with nav bar color picker - Keep Reading horizontal strip in library series list - Home screen: horizontal section rows (Keep Reading, On Deck, etc.) in new UI mode - Pill-shaped filter chips; accent color picker for chips and tabs - New Library UI master toggle in Settings → Appearance (defaults ON) - Collections and read lists: tighter grid spacing in new UI mode - Nav bar icon color fix (correct contrast in dark/light mode) - DB migrations V13 (nav_bar_color, accent_color) and V14 (use_new_library_ui) Co-Authored-By: Claude Sonnet 4.6 --- .../settings/CommonSettingsRepository.kt | 9 + .../kotlin/snd/komelia/db/AppSettings.kt | 4 + .../repository/SettingsRepositoryWrapper.kt | 24 +++ .../files/migrations/app/V13__ui_colors.sql | 2 + .../migrations/app/V14__new_library_ui.sql | 1 + .../komelia/db/migrations/AppMigrations.kt | 2 + .../db/settings/ExposedSettingsRepository.kt | 6 + .../snd/komelia/db/tables/AppSettingsTable.kt | 4 + .../snd/komelia/ui/CompositionLocals.kt | 4 + .../kotlin/snd/komelia/ui/MainScreen.kt | 157 ++++++++++++--- .../kotlin/snd/komelia/ui/MainView.kt | 21 +- .../kotlin/snd/komelia/ui/ViewModelFactory.kt | 1 + .../ui/common/components/DescriptionChips.kt | 32 ++- .../ui/common/itemlist/CollectionLists.kt | 17 +- .../ui/common/itemlist/ReadListLists.kt | 18 +- .../komelia/ui/common/itemlist/SeriesLists.kt | 11 +- .../kotlin/snd/komelia/ui/home/HomeContent.kt | 189 +++++++++++++----- .../snd/komelia/ui/library/LibraryScreen.kt | 16 +- .../ui/library/LibrarySeriesTabState.kt | 28 +++ .../komelia/ui/library/LibraryViewModel.kt | 3 + .../ui/series/list/SeriesListContent.kt | 75 ++++++- .../settings/appearance/AppSettingsScreen.kt | 8 +- .../appearance/AppSettingsViewModel.kt | 23 +++ .../appearance/AppearanceSettingsContent.kt | 156 ++++++++++++++- 24 files changed, 684 insertions(+), 127 deletions(-) create mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V13__ui_colors.sql create mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__new_library_ui.sql diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt index 3ab61f91..d84c1523 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt @@ -39,4 +39,13 @@ interface CommonSettingsRepository { fun getAppTheme(): Flow suspend fun putAppTheme(theme: AppTheme) + + fun getNavBarColor(): Flow + suspend fun putNavBarColor(color: Long?) + + fun getAccentColor(): Flow + suspend fun putAccentColor(color: Long?) + + fun getUseNewLibraryUI(): Flow + suspend fun putUseNewLibraryUI(enabled: Boolean) } \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt index b4ede040..a5d1081e 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt @@ -21,4 +21,8 @@ data class AppSettings( val updateLastCheckedTimestamp: Instant? = null, val updateLastCheckedReleaseVersion: AppVersion? = null, val updateDismissedVersion: AppVersion? = null, + + val navBarColor: Long? = null, + val accentColor: Long? = null, + val useNewLibraryUI: Boolean = true, ) diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt index 8a6bb0ac..cd06e644 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt @@ -103,4 +103,28 @@ class SettingsRepositoryWrapper( wrapper.transform { it.copy(appTheme = theme) } } + override fun getNavBarColor(): Flow { + return wrapper.state.map { it.navBarColor }.distinctUntilChanged() + } + + override suspend fun putNavBarColor(color: Long?) { + wrapper.transform { it.copy(navBarColor = color) } + } + + override fun getAccentColor(): Flow { + return wrapper.state.map { it.accentColor }.distinctUntilChanged() + } + + override suspend fun putAccentColor(color: Long?) { + wrapper.transform { it.copy(accentColor = color) } + } + + override fun getUseNewLibraryUI(): Flow { + return wrapper.state.map { it.useNewLibraryUI }.distinctUntilChanged() + } + + override suspend fun putUseNewLibraryUI(enabled: Boolean) { + wrapper.transform { it.copy(useNewLibraryUI = enabled) } + } + } \ No newline at end of file diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V13__ui_colors.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V13__ui_colors.sql new file mode 100644 index 00000000..b8c20857 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V13__ui_colors.sql @@ -0,0 +1,2 @@ +ALTER TABLE AppSettings ADD COLUMN nav_bar_color TEXT; +ALTER TABLE AppSettings ADD COLUMN accent_color TEXT; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__new_library_ui.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__new_library_ui.sql new file mode 100644 index 00000000..4b700fe0 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__new_library_ui.sql @@ -0,0 +1 @@ +ALTER TABLE AppSettings ADD COLUMN use_new_library_ui INTEGER NOT NULL DEFAULT 1; diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt index 625ba6a9..590c84f3 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt @@ -19,6 +19,8 @@ class AppMigrations : MigrationResourcesProvider() { "V10__komf_settings.sql", "V11__home_filters.sql", "V12__offline_mode.sql", + "V13__ui_colors.sql", + "V14__new_library_ui.sql", ) override suspend fun getMigration(name: String): ByteArray? { diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt index ef934075..3d06465b 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt @@ -39,6 +39,9 @@ class ExposedSettingsRepository(database: Database) : ExposedRepository(database it[updateLastCheckedTimestamp] = settings.updateLastCheckedTimestamp?.toString() it[updateLastCheckedReleaseVersion] = settings.updateLastCheckedReleaseVersion?.toString() it[updateDismissedVersion] = settings.updateDismissedVersion?.toString() + it[navBarColor] = settings.navBarColor?.toString(16) + it[accentColor] = settings.accentColor?.toString(16) + it[useNewLibraryUI] = settings.useNewLibraryUI } } } @@ -60,6 +63,9 @@ class ExposedSettingsRepository(database: Database) : ExposedRepository(database ?.let { AppVersion.fromString(it) }, updateDismissedVersion = get(AppSettingsTable.updateDismissedVersion) ?.let { AppVersion.fromString(it) }, + navBarColor = get(AppSettingsTable.navBarColor)?.toLong(16), + accentColor = get(AppSettingsTable.accentColor)?.toLong(16), + useNewLibraryUI = get(AppSettingsTable.useNewLibraryUI), ) } diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt index eadd9f12..1968d1de 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt @@ -22,5 +22,9 @@ object AppSettingsTable : Table("AppSettings") { val updateLastCheckedReleaseVersion = text("update_last_checked_release_version").nullable() val updateDismissedVersion = text("update_dismissed_version").nullable() + val navBarColor = text("nav_bar_color").nullable() + val accentColor = text("accent_color").nullable() + val useNewLibraryUI = bool("use_new_library_ui").default(true) + override val primaryKey = PrimaryKey(version) } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt index cff3c624..7dba7f79 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt @@ -2,6 +2,7 @@ package snd.komelia.ui import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEvent import com.dokar.sonner.ToasterState import kotlinx.coroutines.flow.SharedFlow @@ -34,3 +35,6 @@ val LocalBookDownloadEvents = staticCompositionLocalOf?> { error("Book download event flow was not initialized") } val LocalOfflineMode = staticCompositionLocalOf> { error("offline mode flow was not initialized") } val LocalKomgaState = staticCompositionLocalOf { error("komga state was not initialized") } +val LocalNavBarColor = compositionLocalOf { null } +val LocalAccentColor = compositionLocalOf { null } +val LocalUseNewLibraryUI = compositionLocalOf { true } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index 78949ac2..dd2e6a06 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -1,5 +1,6 @@ package snd.komelia.ui +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,9 +12,13 @@ import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.LocalLibrary @@ -35,6 +40,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -157,42 +164,131 @@ class MainScreen( vm: MainScreenViewModel ) { val coroutineScope = rememberCoroutineScope() - Scaffold( - containerColor = MaterialTheme.colorScheme.surface, - bottomBar = { - BottomNavigationBar( - navigator = navigator, - toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, - modifier = Modifier - ) - }, - ) { paddingValues -> - val layoutDirection = LocalLayoutDirection.current + val useNewLibraryUI = LocalUseNewLibraryUI.current - ModalNavigationDrawer( - drawerState = vm.navBarState, - drawerContent = { LibrariesNavBar(vm, navigator) }, - content = { - Box( - Modifier - .fillMaxSize() - .padding( - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - top = paddingValues.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding(), - ) - .consumeWindowInsets(paddingValues) - ) { - CurrentScreen() + if (useNewLibraryUI) { + Box(Modifier.fillMaxSize().statusBarsPadding()) { + ModalNavigationDrawer( + drawerState = vm.navBarState, + drawerContent = { LibrariesNavBar(vm, navigator) }, + content = { CurrentScreen() } + ) + Column( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() + ) { + PillBottomNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + ) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } + } + } else { + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + bottomBar = { + StandardBottomNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + modifier = Modifier + ) + }, + ) { paddingValues -> + val layoutDirection = LocalLayoutDirection.current + ModalNavigationDrawer( + drawerState = vm.navBarState, + drawerContent = { LibrariesNavBar(vm, navigator) }, + content = { + Box( + Modifier + .fillMaxSize() + .padding( + start = paddingValues.calculateStartPadding(layoutDirection), + end = paddingValues.calculateEndPadding(layoutDirection), + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding(), + ) + .consumeWindowInsets(paddingValues) + ) { + CurrentScreen() + } } + ) + } + } + } + + @Composable + private fun PillBottomNavigationBar( + navigator: Navigator, + toggleLibrariesDrawer: () -> Unit, + ) { + val pillColor = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surfaceVariant + Box( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(50), + color = pillColor, + shadowElevation = 12.dp, + tonalElevation = 4.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillNavItem( + icon = Icons.Default.LocalLibrary, + onClick = { toggleLibrariesDrawer() }, + isSelected = false, + ) + PillNavItem( + icon = Icons.Default.Home, + onClick = { navigator.replaceAll(HomeScreen()) }, + isSelected = navigator.lastItem is HomeScreen, + ) + PillNavItem( + icon = Icons.Default.Search, + onClick = { navigator.push(SearchScreen(null)) }, + isSelected = navigator.lastItem is SearchScreen, + ) + PillNavItem( + icon = Icons.Default.Settings, + onClick = { navigator.parent!!.push(MobileSettingsScreen()) }, + isSelected = navigator.lastItem is SettingsScreen, + ) } - ) + } } } @Composable - private fun BottomNavigationBar( + private fun PillNavItem( + icon: ImageVector, + onClick: () -> Unit, + isSelected: Boolean, + ) { + val pillColor = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surfaceVariant + val bgColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent + val iconTint = if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer + else contentColorFor(pillColor) + Box( + modifier = Modifier + .clip(CircleShape) + .background(bgColor) + .clickable { onClick() } + .cursorForHand() + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = iconTint) + } + } + + @Composable + private fun StandardBottomNavigationBar( navigator: Navigator, toggleLibrariesDrawer: () -> Unit, modifier: Modifier @@ -222,7 +318,6 @@ class MainScreen( modifier = Modifier.weight(1f) ) - CompactNavButton( text = "Search", icon = Icons.Default.Search, @@ -238,7 +333,6 @@ class MainScreen( isSelected = navigator.lastItem is SettingsScreen, modifier = Modifier.weight(1f) ) - } Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } @@ -272,7 +366,6 @@ class MainScreen( } } - @Composable private fun NavBar( vm: MainScreenViewModel, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt index 1b4a46a4..7adb1555 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -68,9 +69,24 @@ fun MainView( keyEvents: SharedFlow ) { var theme by rememberSaveable { mutableStateOf(Theme.DARK) } + var navBarColor by remember { mutableStateOf(null) } + var accentColor by remember { mutableStateOf(null) } + var useNewLibraryUI by remember { mutableStateOf(true) } LaunchedEffect(dependencies) { dependencies?.appRepositories?.settingsRepository?.getAppTheme()?.collect { theme = it.toTheme() } } + LaunchedEffect(dependencies) { + dependencies?.appRepositories?.settingsRepository?.getNavBarColor() + ?.collect { navBarColor = it?.let { v -> Color(v.toInt()) } } + } + LaunchedEffect(dependencies) { + dependencies?.appRepositories?.settingsRepository?.getAccentColor() + ?.collect { accentColor = it?.let { v -> Color(v.toInt()) } } + } + LaunchedEffect(dependencies) { + dependencies?.appRepositories?.settingsRepository?.getUseNewLibraryUI() + ?.collect { useNewLibraryUI = it } + } MaterialTheme(colorScheme = theme.colorScheme) { ConfigurePlatformTheme(theme) @@ -114,7 +130,10 @@ fun MainView( LocalReloadEvents provides viewModelFactory.screenReloadEvents, LocalBookDownloadEvents provides dependencies.offlineDependencies.bookDownloadEvents, LocalOfflineMode provides dependencies.isOffline, - LocalKomgaState provides dependencies.komgaSharedState + LocalKomgaState provides dependencies.komgaSharedState, + LocalNavBarColor provides navBarColor, + LocalAccentColor provides accentColor, + LocalUseNewLibraryUI provides useNewLibraryUI, ) { MainContent(platformType, dependencies.komgaSharedState) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/ViewModelFactory.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/ViewModelFactory.kt index b34fbc4f..fdecd16c 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/ViewModelFactory.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/ViewModelFactory.kt @@ -122,6 +122,7 @@ class ViewModelFactory( libraryApi = komgaApi.libraryApi, collectionApi = komgaApi.collectionsApi, readListsApi = komgaApi.readListApi, + bookApi = komgaApi.bookApi, seriesApi = komgaApi.seriesApi, referentialApi = komgaApi.referentialApi, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt index d2f5db62..f5e31c49 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt @@ -12,12 +12,16 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Shape +import snd.komelia.ui.LocalAccentColor +import snd.komelia.ui.LocalUseNewLibraryUI import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -117,9 +121,27 @@ fun NoPaddingChip( object AppFilterChipDefaults { @Composable - fun filterChipColors() = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - selectedContainerColor = MaterialTheme.colorScheme.primary, - selectedLabelColor = MaterialTheme.colorScheme.onPrimary - ) + fun shape(): Shape { + return if (LocalUseNewLibraryUI.current) RoundedCornerShape(percent = 50) + else FilterChipDefaults.shape + } + + @Composable + fun filterChipColors(): androidx.compose.material3.SelectableChipColors { + val accent = LocalAccentColor.current ?: MaterialTheme.colorScheme.primary + val onAccent = if (0.299 * accent.red + 0.587 * accent.green + 0.114 * accent.blue > 0.5f) + Color.Black else Color.White + return FilterChipDefaults.filterChipColors( + containerColor = Color.Transparent, + labelColor = accent, + selectedContainerColor = accent, + selectedLabelColor = onAccent, + ) + } + + @Composable + fun filterChipBorder(selected: Boolean): BorderStroke? { + val accent = LocalAccentColor.current ?: MaterialTheme.colorScheme.primary + return if (selected) null else BorderStroke(1.dp, accent) + } } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/CollectionLists.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/CollectionLists.kt index b83be54b..acd22e78 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/CollectionLists.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/CollectionLists.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridState @@ -18,9 +17,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.common.cards.CollectionImageCard import snd.komelia.ui.common.components.Pagination import snd.komelia.ui.platform.VerticalScrollbar +import snd.komelia.ui.platform.VerticalScrollbarWithFullSpans import snd.komga.client.collection.KomgaCollection import snd.komga.client.collection.KomgaCollectionId @@ -36,14 +37,16 @@ fun CollectionLazyCardGrid( scrollState: LazyGridState = rememberLazyGridState(), ) { val coroutineScope = rememberCoroutineScope() + val useNewLibraryUI = LocalUseNewLibraryUI.current + val cardSpacing = if (useNewLibraryUI) 7.dp else 8.dp + val horizontalPadding = 10.dp Box { LazyVerticalGrid( columns = GridCells.Adaptive(minSize), state = scrollState, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(bottom = 30.dp), - modifier = Modifier.padding(horizontal = 10.dp) + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + verticalArrangement = Arrangement.spacedBy(cardSpacing), + contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding, bottom = 30.dp), ) { item( span = { GridItemSpan(maxLineSpan) }, @@ -61,7 +64,7 @@ fun CollectionLazyCardGrid( collection = it, onCollectionClick = { onCollectionClick(it.id) }, onCollectionDelete = { onCollectionDelete(it.id) }, - modifier = Modifier.fillMaxSize().padding(5.dp), + modifier = Modifier.fillMaxSize(), ) } @@ -82,6 +85,6 @@ fun CollectionLazyCardGrid( } - VerticalScrollbar(scrollState, Modifier.align(Alignment.TopEnd)) + VerticalScrollbarWithFullSpans(scrollState, Modifier.align(Alignment.TopEnd), 2) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/ReadListLists.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/ReadListLists.kt index c3eb4ebe..18f177b1 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/ReadListLists.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/ReadListLists.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridState @@ -18,9 +17,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.common.cards.ReadListImageCard import snd.komelia.ui.common.components.Pagination -import snd.komelia.ui.platform.VerticalScrollbar +import snd.komelia.ui.platform.VerticalScrollbarWithFullSpans import snd.komga.client.readlist.KomgaReadList import snd.komga.client.readlist.KomgaReadListId @@ -36,14 +36,16 @@ fun ReadListLazyCardGrid( scrollState: LazyGridState = rememberLazyGridState(), ) { val coroutineScope = rememberCoroutineScope() + val useNewLibraryUI = LocalUseNewLibraryUI.current + val cardSpacing = if (useNewLibraryUI) 7.dp else 8.dp + val horizontalPadding = 10.dp Box { LazyVerticalGrid( columns = GridCells.Adaptive(minSize), state = scrollState, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(bottom = 30.dp), - modifier = Modifier.padding(horizontal = 10.dp) + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + verticalArrangement = Arrangement.spacedBy(cardSpacing), + contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding, bottom = 30.dp), ) { item( span = { GridItemSpan(maxLineSpan) }, @@ -61,7 +63,7 @@ fun ReadListLazyCardGrid( readLists = it, onCollectionClick = { onReadListClick(it.id) }, onCollectionDelete = { onReadListDelete(it.id) }, - modifier = Modifier.fillMaxSize().padding(5.dp), + modifier = Modifier.fillMaxSize(), ) } item( @@ -81,6 +83,6 @@ fun ReadListLazyCardGrid( } - VerticalScrollbar(scrollState, Modifier.align(Alignment.TopEnd)) + VerticalScrollbarWithFullSpans(scrollState, Modifier.align(Alignment.TopEnd), 2) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt index e08dcd2f..96594b18 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt @@ -34,6 +34,7 @@ import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyGridState import sh.calvin.reorderable.rememberReorderableLazyGridState import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.common.cards.DraggableImageCard import snd.komelia.ui.common.cards.SeriesImageCard import snd.komelia.ui.common.components.Pagination @@ -75,14 +76,16 @@ fun SeriesLazyCardGrid( } + val useNewLibraryUI = LocalUseNewLibraryUI.current + val cardSpacing = if (useNewLibraryUI) 7.dp else 15.dp + val horizontalPadding = if (useNewLibraryUI) 10.dp else 20.dp Box(modifier) { LazyVerticalGrid( state = gridState, columns = GridCells.Adaptive(minSize), - horizontalArrangement = Arrangement.spacedBy(15.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - contentPadding = PaddingValues(bottom = 50.dp), - modifier = Modifier.padding(horizontal = 20.dp) + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + verticalArrangement = Arrangement.spacedBy(cardSpacing), + contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding, bottom = 50.dp), ) { item(span = { GridItemSpan(maxLineSpan) }) { beforeContent() diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt index 8b310336..ae0f4ba3 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -26,8 +28,6 @@ import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -38,12 +38,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.common.cards.BookImageCard +import snd.komelia.ui.common.components.AppFilterChipDefaults import snd.komelia.ui.common.cards.SeriesImageCard import snd.komelia.ui.common.menus.BookMenuActions import snd.komelia.ui.common.menus.SeriesMenuActions @@ -66,15 +69,20 @@ fun HomeContent( onBookReadClick: (KomeliaBook, Boolean) -> Unit, ) { val gridState = rememberLazyGridState() + val columnState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + val useNewLibraryUI = LocalUseNewLibraryUI.current Column { Toolbar( filters = filters, currentFilterNumber = activeFilterNumber, onEditStart = onEditStart, - onFilterChange = { - onFilterChange(it) - coroutineScope.launch { gridState.animateScrollToItem(0) } + onFilterChange = { newFilter -> + onFilterChange(newFilter) + coroutineScope.launch { + if (useNewLibraryUI && newFilter == 0) columnState.animateScrollToItem(0) + else gridState.animateScrollToItem(0) + } }, ) DisplayContent( @@ -82,6 +90,7 @@ fun HomeContent( activeFilterNumber = activeFilterNumber, gridState = gridState, + columnState = columnState, cardWidth = cardWidth, onSeriesClick = onSeriesClick, seriesMenuActions = seriesMenuActions, @@ -99,11 +108,7 @@ private fun Toolbar( onFilterChange: (Int) -> Unit, onEditStart: () -> Unit ) { - val chipColors = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - selectedContainerColor = MaterialTheme.colorScheme.primary, - selectedLabelColor = MaterialTheme.colorScheme.onPrimary - ) + val chipColors = AppFilterChipDefaults.filterChipColors() val nonEmptyFilters = remember(filters) { filters.filter { when (it) { @@ -140,12 +145,14 @@ private fun Toolbar( if (filters.size > 1) { item { + val selected = currentFilterNumber == 0 FilterChip( onClick = { onFilterChange(0) }, - selected = currentFilterNumber == 0, + selected = selected, label = { Text("All") }, colors = chipColors, - border = null, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected), ) } } @@ -157,12 +164,14 @@ private fun Toolbar( } } if (display) { + val selected = currentFilterNumber == data.filter.order || filters.size == 1 FilterChip( onClick = { onFilterChange(data.filter.order) }, - selected = currentFilterNumber == data.filter.order || filters.size == 1, + selected = selected, label = { Text(data.filter.label) }, colors = chipColors, - border = null, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected), ) } } @@ -200,6 +209,7 @@ private fun DisplayContent( filters: List, activeFilterNumber: Int, gridState: LazyGridState, + columnState: LazyListState, cardWidth: Dp, onSeriesClick: (KomgaSeries) -> Unit, seriesMenuActions: SeriesMenuActions, @@ -207,38 +217,116 @@ private fun DisplayContent( onBookClick: (KomeliaBook) -> Unit, onBookReadClick: (KomeliaBook, Boolean) -> Unit, ) { - LazyVerticalGrid( - modifier = Modifier.padding(horizontal = 20.dp), - state = gridState, - columns = GridCells.Adaptive(cardWidth), - horizontalArrangement = Arrangement.spacedBy(15.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - contentPadding = PaddingValues(bottom = 50.dp) - ) { - for (data in filters) { - if (activeFilterNumber == 0 || data.filter.order == activeFilterNumber) { - when (data) { - is BookFilterData -> BookFilterEntry( - label = data.filter.label, - books = data.books, - bookMenuActions = bookMenuActions, - onBookClick = onBookClick, - onBookReadClick = onBookReadClick, - ) - - is SeriesFilterData -> SeriesFilterEntries( - label = data.filter.label, - series = data.series, - onSeriesClick = onSeriesClick, - seriesMenuActions = seriesMenuActions, - ) + val useNewLibraryUI = LocalUseNewLibraryUI.current + if (useNewLibraryUI && activeFilterNumber == 0) { + LazyColumn( + state = columnState, + verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(bottom = 50.dp), + ) { + for (data in filters) { + val isEmpty = when (data) { + is BookFilterData -> data.books.isEmpty() + is SeriesFilterData -> data.series.isEmpty() + } + if (!isEmpty) { + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SectionHeader(data.filter.label) + SectionRow( + data = data, + cardWidth = cardWidth, + onSeriesClick = onSeriesClick, + seriesMenuActions = seriesMenuActions, + bookMenuActions = bookMenuActions, + onBookClick = onBookClick, + onBookReadClick = onBookReadClick, + ) + } + } + } + } + } + } else { + LazyVerticalGrid( + modifier = Modifier.padding(horizontal = 20.dp), + state = gridState, + columns = GridCells.Adaptive(cardWidth), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp), + contentPadding = PaddingValues(bottom = 50.dp) + ) { + for (data in filters) { + if (activeFilterNumber == 0 || data.filter.order == activeFilterNumber) { + when (data) { + is BookFilterData -> BookFilterEntry( + label = data.filter.label, + books = data.books, + bookMenuActions = bookMenuActions, + onBookClick = onBookClick, + onBookReadClick = onBookReadClick, + ) + is SeriesFilterData -> SeriesFilterEntries( + label = data.filter.label, + series = data.series, + onSeriesClick = onSeriesClick, + seriesMenuActions = seriesMenuActions, + ) + } } } } } } +@Composable +private fun SectionHeader(label: String) { + Text( + label, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +@Composable +private fun SectionRow( + data: HomeFilterData, + cardWidth: Dp, + onSeriesClick: (KomgaSeries) -> Unit, + seriesMenuActions: SeriesMenuActions, + bookMenuActions: BookMenuActions, + onBookClick: (KomeliaBook) -> Unit, + onBookReadClick: (KomeliaBook, Boolean) -> Unit, +) { + LazyRow( + contentPadding = PaddingValues(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + when (data) { + is BookFilterData -> items(data.books) { book -> + BookImageCard( + book = book, + onBookClick = { onBookClick(book) }, + onBookReadClick = { onBookReadClick(book, it) }, + bookMenuActions = bookMenuActions, + showSeriesTitle = true, + modifier = Modifier.width(cardWidth), + ) + } + + is SeriesFilterData -> items(data.series) { series -> + SeriesImageCard( + series = series, + onSeriesClick = { onSeriesClick(series) }, + seriesMenuActions = seriesMenuActions, + modifier = Modifier.width(cardWidth), + ) + } + } + } +} + private fun LazyGridScope.BookFilterEntry( label: String, books: List, @@ -249,12 +337,11 @@ private fun LazyGridScope.BookFilterEntry( if (books.isEmpty()) return item(span = { GridItemSpan(maxLineSpan) }) { - - Row(verticalAlignment = Alignment.CenterVertically) { - Text(label, style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.width(10.dp)) - HorizontalDivider() - } + Text( + label, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), + modifier = Modifier.padding(vertical = 4.dp), + ) } items(books) { book -> BookImageCard( @@ -276,13 +363,11 @@ private fun LazyGridScope.SeriesFilterEntries( ) { if (series.isEmpty()) return item(span = { GridItemSpan(maxLineSpan) }) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.width(10.dp)) - HorizontalDivider() - } + Text( + label, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), + modifier = Modifier.padding(vertical = 4.dp), + ) } items(series) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt index e4822452..7ef7c9dc 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt @@ -50,6 +50,8 @@ import snd.komelia.ui.library.view.LibraryReadListsContent import snd.komelia.ui.platform.BackPressHandler import snd.komelia.ui.platform.ScreenPullToRefreshBox import snd.komelia.ui.readlist.ReadListScreen +import snd.komelia.ui.book.bookScreen +import snd.komelia.ui.reader.readerScreen import snd.komelia.ui.series.list.SeriesListContent import snd.komelia.ui.series.seriesScreen import snd.komga.client.common.KomgaAuthor @@ -150,6 +152,11 @@ class LibraryScreen( onPageChange = seriesTabState::onPageChange, minSize = seriesTabState.cardWidth.collectAsState().value, + + keepReadingBooks = seriesTabState.keepReadingBooks, + bookMenuActions = seriesTabState.bookMenuActions(), + onBookClick = { navigator.push(bookScreen(it)) }, + onBookReadClick = { book, mark -> navigator.push(readerScreen(book, mark)) }, ) } } @@ -283,7 +290,8 @@ fun LibraryToolBar( selected = currentTab == SERIES, label = { Text("Series") }, colors = chipColors, - border = null, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == SERIES), ) } @@ -294,7 +302,8 @@ fun LibraryToolBar( selected = currentTab == COLLECTIONS, label = { Text("Collections") }, colors = chipColors, - border = null, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == COLLECTIONS), ) } @@ -305,7 +314,8 @@ fun LibraryToolBar( selected = currentTab == READ_LISTS, label = { Text("Read Lists") }, colors = chipColors, - border = null, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == READ_LISTS), ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibrarySeriesTabState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibrarySeriesTabState.kt index c514c612..972860e8 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibrarySeriesTabState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibrarySeriesTabState.kt @@ -20,23 +20,30 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import snd.komelia.AppNotifications +import snd.komelia.komga.api.KomgaBookApi import snd.komelia.komga.api.KomgaReferentialApi import snd.komelia.komga.api.KomgaSeriesApi +import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.offline.tasks.OfflineTaskEmitter import snd.komelia.settings.CommonSettingsRepository import snd.komelia.ui.LoadState +import snd.komelia.ui.common.menus.BookMenuActions import snd.komelia.ui.common.menus.SeriesMenuActions import snd.komelia.ui.series.SeriesFilter import snd.komelia.ui.series.SeriesFilterState +import snd.komga.client.book.KomgaReadStatus import snd.komga.client.common.KomgaPageRequest +import snd.komga.client.common.KomgaSort.KomgaBooksSort import snd.komga.client.common.KomgaSort.KomgaSeriesSort import snd.komga.client.common.Page import snd.komga.client.library.KomgaLibrary +import snd.komga.client.search.allOfBooks import snd.komga.client.search.allOfSeries import snd.komga.client.series.KomgaSeries import snd.komga.client.sse.KomgaEvent class LibrarySeriesTabState( + private val bookApi: KomgaBookApi, private val seriesApi: KomgaSeriesApi, referentialApi: KomgaReferentialApi, private val notifications: AppNotifications, @@ -56,6 +63,9 @@ class LibrarySeriesTabState( var currentSeriesPage by mutableStateOf(1) private set + var keepReadingBooks by mutableStateOf>(emptyList()) + private set + val isInEditMode = MutableStateFlow(false) var selectedSeries by mutableStateOf>(emptyList()) private set @@ -79,6 +89,7 @@ class LibrarySeriesTabState( pageLoadSize.value = settingsRepository.getSeriesPageLoadSize().first() loadSeriesPage(1) + loadKeepReadingBooks() settingsRepository.getSeriesPageLoadSize() .onEach { @@ -107,6 +118,7 @@ class LibrarySeriesTabState( } fun seriesMenuActions() = SeriesMenuActions(seriesApi, notifications, taskEmitter, screenModelScope) + fun bookMenuActions() = BookMenuActions(bookApi, notifications, screenModelScope, taskEmitter) fun onPageSizeChange(pageSize: Int) { pageLoadSize.value = pageSize @@ -173,6 +185,22 @@ class LibrarySeriesTabState( ) } + private suspend fun loadKeepReadingBooks() { + val lib = library.value ?: return + notifications.runCatchingToNotifications { + keepReadingBooks = bookApi.getBookList( + conditionBuilder = allOfBooks { + library { isEqualTo(lib.id) } + readStatus { isEqualTo(KomgaReadStatus.IN_PROGRESS) } + }, + pageRequest = KomgaPageRequest( + sort = KomgaBooksSort.byReadDateDesc(), + size = 20 + ) + ).content + } + } + private fun delayLoadState(): Deferred { return screenModelScope.async { delay(200) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryViewModel.kt index fffc4364..b4c09a58 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import snd.komelia.AppNotifications +import snd.komelia.komga.api.KomgaBookApi import snd.komelia.komga.api.KomgaCollectionsApi import snd.komelia.komga.api.KomgaLibraryApi import snd.komelia.komga.api.KomgaReadListApi @@ -51,6 +52,7 @@ class LibraryViewModel( private val collectionApi: KomgaCollectionsApi, private val readListsApi: KomgaReadListApi, private val taskEmitter: OfflineTaskEmitter, + bookApi: KomgaBookApi, seriesApi: KomgaSeriesApi, referentialApi: KomgaReferentialApi, @@ -73,6 +75,7 @@ class LibraryViewModel( private val reloadJobsFlow = MutableSharedFlow(1, 0, DROP_OLDEST) val seriesTabState = LibrarySeriesTabState( + bookApi = bookApi, seriesApi = seriesApi, referentialApi = referentialApi, notifications = appNotifications, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt index c7b2061d..6d030f5e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt @@ -5,11 +5,19 @@ import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -26,9 +34,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import snd.komelia.komga.api.model.KomeliaBook +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.LocalWindowWidth +import snd.komelia.ui.common.cards.BookImageCard import snd.komelia.ui.common.components.PageSizeSelectionDropdown import snd.komelia.ui.common.itemlist.SeriesLazyCardGrid +import snd.komelia.ui.common.menus.BookMenuActions import snd.komelia.ui.common.menus.SeriesMenuActions import snd.komelia.ui.common.menus.bulk.BottomPopupBulkActionsPanel import snd.komelia.ui.common.menus.bulk.BulkActionsContainer @@ -64,7 +77,13 @@ fun SeriesListContent( onPageSizeChange: (Int) -> Unit, minSize: Dp, + + keepReadingBooks: List = emptyList(), + bookMenuActions: BookMenuActions? = null, + onBookClick: (KomeliaBook) -> Unit = {}, + onBookReadClick: (KomeliaBook, Boolean) -> Unit = { _, _ -> }, ) { + val useNewLibraryUI = LocalUseNewLibraryUI.current Column { if (editMode) { BulkActionsToolbar( @@ -89,15 +108,46 @@ fun SeriesListContent( beforeContent = { AnimatedVisibility(!editMode) { - ToolBar( - seriesTotalCount = seriesTotalCount, - pageSize = pageSize, - onPageSizeChange = onPageSizeChange, - isLoading = isLoading, - filterState = filterState - ) + Column { + if (useNewLibraryUI && keepReadingBooks.isNotEmpty() && bookMenuActions != null) { + LibrarySectionHeader("Keep Reading") + val gridPadding = 10.dp + val density = LocalDensity.current + LazyRow( + modifier = Modifier.layout { measurable, constraints -> + val insetPx = with(density) { gridPadding.roundToPx() } + val placeable = measurable.measure( + constraints.copy(maxWidth = constraints.maxWidth + insetPx * 2) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(-insetPx, 0) + } + }, + contentPadding = PaddingValues(horizontal = gridPadding), + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + items(keepReadingBooks) { book -> + BookImageCard( + book = book, + onBookClick = { onBookClick(book) }, + onBookReadClick = { onBookReadClick(book, it) }, + bookMenuActions = bookMenuActions, + showSeriesTitle = true, + modifier = Modifier.width(minSize), + ) + } + } + } + if (useNewLibraryUI) LibrarySectionHeader("Browse") + ToolBar( + seriesTotalCount = seriesTotalCount, + pageSize = pageSize, + onPageSizeChange = onPageSizeChange, + isLoading = isLoading, + filterState = filterState + ) + } } - }, minSize = minSize, ) @@ -199,3 +249,12 @@ private fun ToolBar( } } } + +@Composable +private fun LibrarySectionHeader(label: String) { + Text( + label, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.ExtraBold), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt index e0d5efbc..499b4eba 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt @@ -28,7 +28,13 @@ class AppSettingsScreen : Screen { cardWidth = vm.cardWidth, onCardWidthChange = vm::onCardWidthChange, currentTheme = vm.currentTheme, - onThemeChange = vm::onAppThemeChange + onThemeChange = vm::onAppThemeChange, + navBarColor = vm.navBarColor, + onNavBarColorChange = vm::onNavBarColorChange, + accentColor = vm.accentColor, + onAccentColorChange = vm::onAccentColorChange, + useNewLibraryUI = vm.useNewLibraryUI, + onUseNewLibraryUIChange = vm::onUseNewLibraryUIChange, ) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt index c8933974..89147c47 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt @@ -3,6 +3,8 @@ package snd.komelia.ui.settings.appearance import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.StateScreenModel @@ -20,12 +22,18 @@ class AppSettingsViewModel( ) : StateScreenModel>(LoadState.Uninitialized) { var cardWidth by mutableStateOf(defaultCardWidth.dp) var currentTheme by mutableStateOf(AppTheme.DARK) + var navBarColor by mutableStateOf(null) + var accentColor by mutableStateOf(null) + var useNewLibraryUI by mutableStateOf(true) suspend fun initialize() { if (state.value !is LoadState.Uninitialized) return mutableState.value = LoadState.Loading cardWidth = settingsRepository.getCardWidth().map { it.dp }.first() currentTheme = settingsRepository.getAppTheme().first() + navBarColor = settingsRepository.getNavBarColor().first()?.let { Color(it.toInt()) } + accentColor = settingsRepository.getAccentColor().first()?.let { Color(it.toInt()) } + useNewLibraryUI = settingsRepository.getUseNewLibraryUI().first() mutableState.value = LoadState.Success(Unit) } @@ -39,4 +47,19 @@ class AppSettingsViewModel( screenModelScope.launch { settingsRepository.putAppTheme(theme) } } + fun onNavBarColorChange(color: Color?) { + this.navBarColor = color + screenModelScope.launch { settingsRepository.putNavBarColor(color?.toArgb()?.toLong()) } + } + + fun onAccentColorChange(color: Color?) { + this.accentColor = color + screenModelScope.launch { settingsRepository.putAccentColor(color?.toArgb()?.toLong()) } + } + + fun onUseNewLibraryUIChange(enabled: Boolean) { + this.useNewLibraryUI = enabled + screenModelScope.launch { settingsRepository.putUseNewLibraryUI(enabled) } + } + } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt index f4877cfa..9c0ed76b 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt @@ -1,20 +1,33 @@ package snd.komelia.ui.settings.appearance +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import snd.komelia.settings.model.AppTheme @@ -25,18 +38,76 @@ import snd.komelia.ui.common.components.LabeledEntry import snd.komelia.ui.platform.cursorForHand import kotlin.math.roundToInt +private val navBarPresets: List> = listOf( + null to "Auto", + Color(0xFF2D3436.toInt()) to "Charcoal", + Color(0xFF1A1A2E.toInt()) to "Navy", + Color(0xFF0D3B46.toInt()) to "D.Teal", + Color(0xFF1B4332.toInt()) to "Forest", + Color(0xFF3D1A78.toInt()) to "Violet", + Color(0xFF3B82F6.toInt()) to "Blue", + Color(0xFF14B8A6.toInt()) to "Teal", + Color(0xFF8B5CF6.toInt()) to "Purple", + Color(0xFFEC4899.toInt()) to "Pink", + Color(0xFFF97316.toInt()) to "Orange", + Color(0xFF22C55E.toInt()) to "Green", +) + +private val accentPresets: List> = listOf( + null to "Auto", + Color(0xFF3B82F6.toInt()) to "Blue", + Color(0xFF14B8A6.toInt()) to "Teal", + Color(0xFF8B5CF6.toInt()) to "Purple", + Color(0xFFEC4899.toInt()) to "Pink", + Color(0xFFF97316.toInt()) to "Orange", + Color(0xFF22C55E.toInt()) to "Green", + Color(0xFF2D3436.toInt()) to "Charcoal", + Color(0xFF0D3B46.toInt()) to "D.Teal", + Color(0xFF1A1A2E.toInt()) to "Navy", + Color(0xFF1B4332.toInt()) to "Forest", + Color(0xFF3D1A78.toInt()) to "Violet", +) + @Composable fun AppearanceSettingsContent( cardWidth: Dp, onCardWidthChange: (Dp) -> Unit, currentTheme: AppTheme, onThemeChange: (AppTheme) -> Unit, + navBarColor: Color?, + onNavBarColorChange: (Color?) -> Unit, + accentColor: Color?, + onAccentColorChange: (Color?) -> Unit, + useNewLibraryUI: Boolean, + onUseNewLibraryUIChange: (Boolean) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(10.dp), ) { val strings = LocalStrings.current.settings + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("New library UI", style = MaterialTheme.typography.bodyLarge) + Text( + "Floating nav bar, Keep Reading panel, and pill-shaped tabs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = useNewLibraryUI, + onCheckedChange = onUseNewLibraryUIChange, + modifier = Modifier.cursorForHand(), + ) + } + + HorizontalDivider() + DropdownChoiceMenu( label = { Text(strings.appTheme) }, selectedOption = LabeledEntry(currentTheme, strings.forAppTheme(currentTheme)), @@ -45,14 +116,34 @@ fun AppearanceSettingsContent( inputFieldModifier = Modifier.widthIn(min = 250.dp) ) + if (useNewLibraryUI) { + HorizontalDivider() + + Text("Nav Bar Color", modifier = Modifier.padding(10.dp)) + ColorSwatchRow( + presets = navBarPresets, + selectedColor = navBarColor, + onColorSelected = onNavBarColorChange, + ) + + HorizontalDivider() + + Text("Accent Color (chips & tabs)", modifier = Modifier.padding(10.dp)) + ColorSwatchRow( + presets = accentPresets, + selectedColor = accentColor, + onColorSelected = onAccentColorChange, + ) + } + HorizontalDivider() Text(strings.imageCardSize, modifier = Modifier.padding(10.dp)) Slider( value = cardWidth.value, onValueChange = { onCardWidthChange(it.roundToInt().dp) }, - steps = 19, - valueRange = 150f..350f, + steps = 24, + valueRange = 100f..350f, colors = AppSliderDefaults.colors(), modifier = Modifier.cursorForHand().padding(end = 20.dp), ) @@ -70,12 +161,65 @@ fun AppearanceSettingsContent( .width(cardWidth) .aspectRatio(0.703f) ) { - } - - } + } +} +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ColorSwatchRow( + presets: List>, + selectedColor: Color?, + onColorSelected: (Color?) -> Unit, +) { + FlowRow( + modifier = Modifier.padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for ((color, label) in presets) { + ColorSwatch( + color = color, + label = label, + isSelected = color == selectedColor, + onClick = { onColorSelected(color) }, + ) + } } +} -} \ No newline at end of file +@Composable +private fun ColorSwatch( + color: Color?, + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val swatchColor = color ?: MaterialTheme.colorScheme.surfaceVariant + val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(swatchColor) + .border(2.dp, borderColor, CircleShape) + .clickable { onClick() } + .cursorForHand(), + ) { + if (color == null) { + Text( + "A", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center), + ) + } + } + Text(label, style = MaterialTheme.typography.labelSmall) + } +} From 2aa61515274349f67d7612495da251dff6456c5d Mon Sep 17 00:00:00 2001 From: Eyal Date: Tue, 24 Feb 2026 02:33:18 +0200 Subject: [PATCH 02/35] New library UI: immersive detail scaffold with cover behind status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ImmersiveDetailScaffold: draggable card over full-bleed cover image, animated corner radius, nested scroll, FAB and top bar layers - Add LocalRawStatusBarHeight composition local to capture status bar height before statusBarsPadding() consumes it in MainScreen - Wire immersive=true in SeriesScreen, BookScreen, OneshotScreen; replace IconButton (40dp M3 minimum) with Box+clickable for true 36dp circle size; add edge padding so button is not flush with screen edges - Rename migration V14__new_library_ui → V14__immersive_layout; add V15 for new library UI settings Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/app/V14__immersive_layout.sql | 1 + .../migrations/app/V14__new_library_ui.sql | 1 - .../migrations/app/V15__new_library_ui.sql | 1 + .../snd/komelia/ui/CompositionLocals.kt | 3 + .../kotlin/snd/komelia/ui/MainScreen.kt | 32 +-- .../kotlin/snd/komelia/ui/book/BookScreen.kt | 80 +++++++ .../immersive/ImmersiveDetailScaffold.kt | 195 ++++++++++++++++++ .../snd/komelia/ui/oneshot/OneshotScreen.kt | 82 ++++++++ .../snd/komelia/ui/series/SeriesScreen.kt | 82 ++++++++ 9 files changed, 463 insertions(+), 14 deletions(-) create mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__immersive_layout.sql delete mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__new_library_ui.sql create mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V15__new_library_ui.sql create mode 100644 komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__immersive_layout.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__immersive_layout.sql new file mode 100644 index 00000000..0ffe12c6 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__immersive_layout.sql @@ -0,0 +1 @@ +ALTER TABLE AppSettings ADD COLUMN use_immersive_detail_layout INTEGER NOT NULL DEFAULT 0; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__new_library_ui.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__new_library_ui.sql deleted file mode 100644 index 4b700fe0..00000000 --- a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__new_library_ui.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE AppSettings ADD COLUMN use_new_library_ui INTEGER NOT NULL DEFAULT 1; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V15__new_library_ui.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V15__new_library_ui.sql new file mode 100644 index 00000000..2166302f --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V15__new_library_ui.sql @@ -0,0 +1 @@ +ALTER TABLE AppSettings RENAME COLUMN use_immersive_detail_layout TO use_new_library_ui; diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt index 7dba7f79..a8c86822 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.dokar.sonner.ToasterState import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -38,3 +40,4 @@ val LocalKomgaState = staticCompositionLocalOf { error val LocalNavBarColor = compositionLocalOf { null } val LocalAccentColor = compositionLocalOf { null } val LocalUseNewLibraryUI = compositionLocalOf { true } +val LocalRawStatusBarHeight = staticCompositionLocalOf { 0.dp } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index dd2e6a06..328918b1 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight @@ -37,6 +39,7 @@ import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -167,20 +170,23 @@ class MainScreen( val useNewLibraryUI = LocalUseNewLibraryUI.current if (useNewLibraryUI) { - Box(Modifier.fillMaxSize().statusBarsPadding()) { - ModalNavigationDrawer( - drawerState = vm.navBarState, - drawerContent = { LibrariesNavBar(vm, navigator) }, - content = { CurrentScreen() } - ) - Column( - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() - ) { - PillBottomNavigationBar( - navigator = navigator, - toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + val rawStatusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + CompositionLocalProvider(LocalRawStatusBarHeight provides rawStatusBarHeight) { + Box(Modifier.fillMaxSize().statusBarsPadding()) { + ModalNavigationDrawer( + drawerState = vm.navBarState, + drawerContent = { LibrariesNavBar(vm, navigator) }, + content = { CurrentScreen() } ) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + Column( + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() + ) { + PillBottomNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + ) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } } } } else { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt index fee5d8d7..63f8f8e9 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt @@ -27,6 +27,33 @@ import snd.komga.client.book.KomgaBookId import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import snd.komelia.image.coil.BookDefaultThumbnailRequest +import snd.komelia.ui.LocalAccentColor +import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.platform.PlatformType + fun bookScreen( book: KomeliaBook, bookSiblingsContext: BookSiblingsContext? = null @@ -66,6 +93,59 @@ class BookScreen( onDispose { vm.stopKomgaEventHandler() } } + val platform = LocalPlatform.current + val useNewUI = LocalUseNewLibraryUI.current + if (platform == PlatformType.MOBILE && useNewUI) { + ImmersiveDetailScaffold( + coverData = BookDefaultThumbnailRequest(bookId), + coverKey = "book-$bookId", + cardColor = LocalAccentColor.current, + immersive = true, + topBarContent = { + Box( + modifier = Modifier + .padding(start = 12.dp, top = 8.dp) + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { onBackPress(navigator, vm.book.value?.seriesId) }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) + } + }, + fabContent = { + Button( + onClick = {}, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text("Boilerplate FAB") + } + }, + cardContent = { expandFraction -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .padding(start = (126.dp * expandFraction).coerceAtLeast(0.dp)) + ) { + Text( + text = vm.book.collectAsState().value?.metadata?.title ?: "Loading...", + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(16.dp)) + Text("Immersive Book Boilerplate") + Text("Scroll anywhere on the card to see the cover shrink animation.") + + // Add some height to enable scrolling/dragging if needed + Spacer(Modifier.height(1000.dp)) + } + } + ) + + BackPressHandler { onBackPress(navigator, vm.book.value?.seriesId) } + return + } + val book = vm.book.collectAsState().value ScreenPullToRefreshBox( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt new file mode 100644 index 00000000..31b2e62d --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -0,0 +1,195 @@ +package snd.komelia.ui.common.immersive + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import snd.komelia.ui.LocalRawStatusBarHeight +import snd.komelia.ui.common.images.ThumbnailImage +import kotlin.math.roundToInt + +private enum class CardDragValue { COLLAPSED, EXPANDED } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ImmersiveDetailScaffold( + coverData: Any, + coverKey: String, + cardColor: Color?, + modifier: Modifier = Modifier, + immersive: Boolean = false, + topBarContent: @Composable () -> Unit, + fabContent: @Composable () -> Unit, + cardContent: @Composable ColumnScope.(expandFraction: Float) -> Unit, +) { + val density = LocalDensity.current + val backgroundColor = cardColor ?: MaterialTheme.colorScheme.surfaceVariant + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val screenHeight = maxHeight + val collapsedOffset = screenHeight * 0.65f + val collapsedOffsetPx = with(density) { collapsedOffset.toPx() } + + val state = remember(collapsedOffsetPx) { + AnchoredDraggableState( + initialValue = CardDragValue.COLLAPSED, + anchors = DraggableAnchors { + CardDragValue.COLLAPSED at collapsedOffsetPx + CardDragValue.EXPANDED at 0f + }, + positionalThreshold = { d -> d * 0.5f }, + velocityThreshold = { with(density) { 100.dp.toPx() } }, + // M3 Standard easing (0.2, 0, 0, 1) + Long2 (500ms) — spatial movement within screen + snapAnimationSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)), + decayAnimationSpec = exponentialDecay(), + ) + } + + val cardOffsetPx = if (state.offset.isNaN()) collapsedOffsetPx else state.offset + val expandFraction = (1f - cardOffsetPx / collapsedOffsetPx).coerceIn(0f, 1f) + + val nestedScrollConnection = remember(state) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + return if (delta < 0 && cardOffsetPx > 0f) + Offset(0f, state.dispatchRawDelta(delta)) + else Offset.Zero + } + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + return if (delta > 0 && source == NestedScrollSource.UserInput) + Offset(0f, state.dispatchRawDelta(delta)) + else Offset.Zero + } + } + } + + val topCornerRadiusDp = lerp(28f, 0f, expandFraction).dp + val statusBarDp = LocalRawStatusBarHeight.current + val statusBarPx = with(density) { statusBarDp.toPx() } + + Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + + // Layer 1: Cover image — fades out as card expands + // Extends by the card corner radius so it fills behind the rounded corners + // When immersive=true, shifts up behind the status bar + ThumbnailImage( + data = coverData, + cacheKey = coverKey, + contentScale = ContentScale.Crop, + modifier = if (immersive) + Modifier + .fillMaxWidth() + .offset { IntOffset(0, -statusBarPx.roundToInt()) } + .height(collapsedOffset + topCornerRadiusDp + statusBarDp) + .graphicsLayer { alpha = 1f - expandFraction } + else + Modifier + .fillMaxWidth() + .height(collapsedOffset + topCornerRadiusDp) + .graphicsLayer { alpha = 1f - expandFraction } + ) + + // Layer 2: Card + Column( + modifier = Modifier + .offset { IntOffset(0, cardOffsetPx.roundToInt()) } + .fillMaxWidth() + .height(screenHeight) + .nestedScroll(nestedScrollConnection) + .anchoredDraggable(state, Orientation.Vertical) + .clip(RoundedCornerShape(topStart = topCornerRadiusDp, topEnd = topCornerRadiusDp)) + .background(backgroundColor) + ) { + Box( + modifier = Modifier.fillMaxWidth().height(28.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(width = 32.dp, height = 4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + ) + } + Column(modifier = Modifier.fillMaxWidth().weight(1f)) { + cardContent(expandFraction) + } + } + + // Layer 3: Thumbnail — fades in as card expands, moves with the card + // Positioned at card top + drag handle (28dp) + small gap (8dp), left-aligned with 16dp margin + val thumbAlpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) + Box( + modifier = Modifier + .offset { + IntOffset( + x = with(density) { 16.dp.toPx() }.roundToInt(), + y = (cardOffsetPx + with(density) { (28.dp + 20.dp).toPx() }).roundToInt() + ) + } + .graphicsLayer { alpha = thumbAlpha } + ) { + ThumbnailImage( + data = coverData, + cacheKey = coverKey, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = (110.dp / 0.703f)) + .clip(RoundedCornerShape(8.dp)) + ) + } + + // Layer 4: FAB + val fabAlpha = (1f - expandFraction * 3f).coerceIn(0f, 1f) + Box( + modifier = Modifier + .offset { IntOffset(0, (cardOffsetPx - with(density) { 72.dp.toPx() }).roundToInt()) } + .fillMaxWidth() + .graphicsLayer { alpha = fabAlpha } + ) { + fabContent() + } + + // Layer 5: Top bar + Box(modifier = Modifier.fillMaxWidth().statusBarsPadding()) { + topBarContent() + } + } + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt index ee2fbe45..643519b2 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt @@ -31,6 +31,33 @@ import snd.komga.client.series.KomgaSeries import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import snd.komelia.ui.LocalAccentColor +import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.platform.PlatformType + class OneshotScreen( val seriesId: KomgaSeriesId, private val bookSiblingsContext: BookSiblingsContext, @@ -71,6 +98,61 @@ class OneshotScreen( onDispose { vm.stopKomgaEventHandler() } } + val platform = LocalPlatform.current + val useNewUI = LocalUseNewLibraryUI.current + if (platform == PlatformType.MOBILE && useNewUI) { + ImmersiveDetailScaffold( + coverData = SeriesDefaultThumbnailRequest(seriesId), + coverKey = "series-$seriesId", + cardColor = LocalAccentColor.current, + immersive = true, + topBarContent = { + Box( + modifier = Modifier + .padding(start = 12.dp, top = 8.dp) + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { onBackPress(navigator, vm.series.value?.libraryId) }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) + } + }, + fabContent = { + Button( + onClick = {}, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text("Boilerplate FAB") + } + }, + cardContent = { expandFraction -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .padding(start = (126.dp * expandFraction).coerceAtLeast(0.dp)) + ) { + Text( + text = vm.series.collectAsState().value?.metadata?.title ?: "Loading...", + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(16.dp)) + Text("Immersive Oneshot Boilerplate") + Text("Scroll anywhere on the card to see the cover shrink animation.") + + // Add some height to enable scrolling/dragging if needed + Spacer(Modifier.height(1000.dp)) + } + } + ) + + BackPressHandler { + vm.series.value?.let { onBackPress(navigator, it.libraryId) } + } + return + } + val state = vm.state.collectAsState().value val book = vm.book.collectAsState().value val library = vm.library.collectAsState().value diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt index 89c6677b..54921cc4 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt @@ -30,6 +30,33 @@ import snd.komga.client.series.KomgaSeries import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import snd.komelia.ui.LocalAccentColor +import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.platform.PlatformType + fun seriesScreen(series: KomgaSeries): Screen = if (series.oneshot) OneshotScreen(series, BookSiblingsContext.Series) else SeriesScreen(series) @@ -73,6 +100,61 @@ class SeriesScreen( onDispose { vm.stopKomgaEventHandler() } } + val platform = LocalPlatform.current + val useNewUI = LocalUseNewLibraryUI.current + if (platform == PlatformType.MOBILE && useNewUI) { + ImmersiveDetailScaffold( + coverData = SeriesDefaultThumbnailRequest(seriesId), + coverKey = "series-$seriesId", + cardColor = LocalAccentColor.current, + immersive = true, + topBarContent = { + Box( + modifier = Modifier + .padding(start = 12.dp, top = 8.dp) + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { onBackPress(navigator, vm.series.value?.libraryId) }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) + } + }, + fabContent = { + Button( + onClick = {}, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text("Boilerplate FAB") + } + }, + cardContent = { expandFraction -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .padding(start = (126.dp * expandFraction).coerceAtLeast(0.dp)) + ) { + Text( + text = vm.series.collectAsState().value?.metadata?.title ?: "Loading...", + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(16.dp)) + Text("Immersive Detail Boilerplate") + Text("Scroll anywhere on the card to see the cover shrink animation.") + + // Add some height to enable scrolling/dragging if needed + Spacer(Modifier.height(1000.dp)) + } + } + ) + + BackPressHandler { + vm.series.value?.let { onBackPress(navigator, it.libraryId) } + } + return + } + ScreenPullToRefreshBox(screenState = vm.state, onRefresh = vm::reload) { when (val state = vm.state.collectAsState().value) { is Error -> ErrorContent( From 40d0de3acd0ae51e0bf2205f4e1255acdbebf289 Mon Sep 17 00:00:00 2001 From: Eyal Date: Tue, 24 Feb 2026 17:22:16 +0200 Subject: [PATCH 03/35] New library UI: ImmersiveSeriesContent + card shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ImmersiveSeriesContent using ImmersiveDetailScaffold; wired into SeriesScreen behind MOBILE && useNewLibraryUI flag - ImmersiveDetailFab: read / read-incognito / download with confirmation dialog - Card layout: title (2/3 headlineMedium) → writers+year → SeriesDescriptionRow → summary → chip tags → tab row → books/collections content - Bulk-select support: top bar swaps to BulkActionsContainer in selection mode, BottomPopupBulkActionsPanel shown when books selected - SeriesDescriptionRow: add showReleaseYear param (default true) - ImmersiveDetailScaffold: add 2dp shadow before clip so shadow isn't clipped Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 18 + .../kotlin/snd/komelia/ui/MainScreen.kt | 16 +- .../kotlin/snd/komelia/ui/book/BookScreen.kt | 14 +- .../ui/common/immersive/ImmersiveDetailFab.kt | 133 ++++++ .../immersive/ImmersiveDetailScaffold.kt | 162 +++++-- .../snd/komelia/ui/oneshot/OneshotScreen.kt | 14 +- .../snd/komelia/ui/series/SeriesScreen.kt | 94 ++-- .../immersive/ImmersiveSeriesContent.kt | 407 ++++++++++++++++++ .../ui/series/view/SeriesDescriptionRow.kt | 3 +- 9 files changed, 745 insertions(+), 116 deletions(-) create mode 100644 komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt create mode 100644 komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt diff --git a/.gitignore b/.gitignore index b3972f34..be3f6f5b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,21 @@ build/ /output /.kotlin /cmake/build* + +# Tooling / AI assistants +.github/ +.gemini/ +.claude/ +agent-os/ +.beans/ +.beans.yml +CLAUDE.md + +# Design / working files +New UI/ + +# Pre-existing vendored third-party repos +third_party/secret-service/ + +# Prebuilt native libraries +**/androidMain/jniLibs/**/*.so diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index 328918b1..95b3b605 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -60,9 +60,12 @@ import cafe.adriel.voyager.navigator.CurrentScreen import cafe.adriel.voyager.navigator.Navigator import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch +import snd.komelia.ui.book.BookScreen import snd.komelia.ui.book.bookScreen import snd.komelia.ui.home.HomeScreen import snd.komelia.ui.library.LibraryScreen +import snd.komelia.ui.oneshot.OneshotScreen +import snd.komelia.ui.series.SeriesScreen import snd.komelia.ui.platform.PlatformType.DESKTOP import snd.komelia.ui.platform.PlatformType.MOBILE import snd.komelia.ui.platform.PlatformType.WEB_KOMF @@ -178,13 +181,18 @@ class MainScreen( drawerContent = { LibrariesNavBar(vm, navigator) }, content = { CurrentScreen() } ) + val isImmersiveScreen = navigator.lastItem is SeriesScreen || + navigator.lastItem is BookScreen || + navigator.lastItem is OneshotScreen Column( modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() ) { - PillBottomNavigationBar( - navigator = navigator, - toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, - ) + if (!isImmersiveScreen) { + PillBottomNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + ) + } Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt index 63f8f8e9..9b29b538 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -51,6 +50,7 @@ import snd.komelia.image.coil.BookDefaultThumbnailRequest import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalPlatform import snd.komelia.ui.LocalUseNewLibraryUI +import snd.komelia.ui.common.immersive.ImmersiveDetailFab import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold import snd.komelia.ui.platform.PlatformType @@ -114,12 +114,12 @@ class BookScreen( } }, fabContent = { - Button( - onClick = {}, - modifier = Modifier.padding(horizontal = 16.dp) - ) { - Text("Boilerplate FAB") - } + ImmersiveDetailFab( + onReadClick = {}, + onReadIncognitoClick = {}, + onDownloadClick = {}, + accentColor = LocalAccentColor.current, + ) }, cardContent = { expandFraction -> Column( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt new file mode 100644 index 00000000..c5081333 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt @@ -0,0 +1,133 @@ +package snd.komelia.ui.common.immersive + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp + +@Composable +fun ImmersiveDetailFab( + onReadClick: () -> Unit, + onReadIncognitoClick: () -> Unit, + onDownloadClick: () -> Unit, + accentColor: Color? = null, + showReadActions: Boolean = true, +) { + val pillBackground = accentColor ?: MaterialTheme.colorScheme.primaryContainer + val pillContentColor = remember(pillBackground) { + if (pillBackground.luminance() > 0.35f) Color(0xFF1C1B1F) else Color(0xFFFFFFFF) + } + val fabBackground = accentColor?.let { + if (it.luminance() > 0.5f) it.copy(alpha = 0.75f) else it.copy(alpha = 0.9f) + } ?: MaterialTheme.colorScheme.secondaryContainer + val fabContentColor = remember(fabBackground) { + if (fabBackground.luminance() > 0.35f) Color(0xFF1C1B1F) else Color(0xFFFFFFFF) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + if (showReadActions) { + // Split pill: Read Now (2/3) | Incognito (1/3) + Surface( + shape = CircleShape, + color = pillBackground, + modifier = Modifier + .weight(1f) + .height(56.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(56.dp) + ) { + // Read Now + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .weight(2f) + .fillMaxHeight() + .clickable(onClick = onReadClick) + .padding(horizontal = 20.dp) + ) { + Icon( + Icons.AutoMirrored.Rounded.MenuBook, + contentDescription = "Read Now", + tint = pillContentColor + ) + Text( + text = "Read Now", + style = MaterialTheme.typography.labelLarge, + color = pillContentColor + ) + } + + VerticalDivider( + modifier = Modifier.fillMaxHeight(0.6f), + color = pillContentColor.copy(alpha = 0.3f) + ) + + // Incognito + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable(onClick = onReadIncognitoClick) + .padding(horizontal = 16.dp) + ) { + Icon( + Icons.Default.VisibilityOff, + contentDescription = "Read Incognito", + tint = pillContentColor + ) + } + } + } + } else { + // Spacer to push download FAB to the right + Box(modifier = Modifier.weight(1f)) + } + + // Download FAB + FloatingActionButton( + onClick = onDownloadClick, + modifier = Modifier.size(56.dp), + shape = CircleShape, + containerColor = fabBackground, + ) { + Icon( + Icons.Filled.Download, + contentDescription = "Download", + tint = fabContentColor + ) + } + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index 31b2e62d..f540bb16 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -1,6 +1,10 @@ package snd.komelia.ui.common.immersive +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.VectorizedAnimationSpec import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi @@ -13,20 +17,31 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -36,6 +51,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import snd.komelia.ui.LocalRawStatusBarHeight @@ -44,6 +60,32 @@ import kotlin.math.roundToInt private enum class CardDragValue { COLLAPSED, EXPANDED } +private class DirectionalSnapSpec : AnimationSpec { + override fun vectorize( + converter: TwoWayConverter + ): VectorizedAnimationSpec { + val expandSpec = tween( + durationMillis = 500, + easing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) + ).vectorize(converter) + val collapseSpec = tween( + durationMillis = 200, + easing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + ).vectorize(converter) + return object : VectorizedAnimationSpec { + override val isInfinite = false + private fun pick(initialValue: V, targetValue: V) = + if (converter.convertFromVector(targetValue) < converter.convertFromVector(initialValue)) expandSpec else collapseSpec + override fun getDurationNanos(initialValue: V, initialVelocity: V, targetValue: V) = + pick(initialValue, targetValue).getDurationNanos(initialValue, initialVelocity, targetValue) + override fun getValueFromNanos(playTimeNanos: Long, initialValue: V, targetValue: V, initialVelocity: V) = + pick(initialValue, targetValue).getValueFromNanos(playTimeNanos, initialValue, targetValue, initialVelocity) + override fun getVelocityFromNanos(playTimeNanos: Long, initialValue: V, targetValue: V, initialVelocity: V) = + pick(initialValue, targetValue).getVelocityFromNanos(playTimeNanos, initialValue, targetValue, initialVelocity) + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun ImmersiveDetailScaffold( @@ -64,17 +106,20 @@ fun ImmersiveDetailScaffold( val collapsedOffset = screenHeight * 0.65f val collapsedOffsetPx = with(density) { collapsedOffset.toPx() } + // Persist expanded/collapsed across back-navigation + var savedExpanded by rememberSaveable { mutableStateOf(false) } + val state = remember(collapsedOffsetPx) { AnchoredDraggableState( - initialValue = CardDragValue.COLLAPSED, + initialValue = if (savedExpanded) CardDragValue.EXPANDED else CardDragValue.COLLAPSED, anchors = DraggableAnchors { CardDragValue.COLLAPSED at collapsedOffsetPx CardDragValue.EXPANDED at 0f }, positionalThreshold = { d -> d * 0.5f }, velocityThreshold = { with(density) { 100.dp.toPx() } }, - // M3 Standard easing (0.2, 0, 0, 1) + Long2 (500ms) — spatial movement within screen - snapAnimationSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)), + // M3 Emphasize Decelerate (expand, 500ms) / Emphasize Accelerate (collapse, 200ms) + snapAnimationSpec = DirectionalSnapSpec(), decayAnimationSpec = exponentialDecay(), ) } @@ -82,19 +127,66 @@ fun ImmersiveDetailScaffold( val cardOffsetPx = if (state.offset.isNaN()) collapsedOffsetPx else state.offset val expandFraction = (1f - cardOffsetPx / collapsedOffsetPx).coerceIn(0f, 1f) + var innerScrollPx by rememberSaveable { mutableFloatStateOf(0f) } + + LaunchedEffect(state.currentValue) { + savedExpanded = state.currentValue == CardDragValue.EXPANDED + if (state.currentValue == CardDragValue.COLLAPSED) innerScrollPx = 0f + } + val nestedScrollConnection = remember(state) { object : NestedScrollConnection { + var preScrollConsumedY = 0f + var lastGestureWasExpand = false + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset val delta = available.y - return if (delta < 0 && cardOffsetPx > 0f) - Offset(0f, state.dispatchRawDelta(delta)) - else Offset.Zero + return if (delta < 0 && currentOffset > 0f) { + val consumed = state.dispatchRawDelta(delta) + preScrollConsumedY = consumed + if (consumed != 0f) lastGestureWasExpand = true + Offset(0f, consumed) + } else { + preScrollConsumedY = 0f + Offset.Zero + } } + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + val innerConsumedY = consumed.y - preScrollConsumedY + if (innerConsumedY != 0f) + innerScrollPx = (innerScrollPx - innerConsumedY).coerceAtLeast(0f) + val delta = available.y - return if (delta > 0 && source == NestedScrollSource.UserInput) - Offset(0f, state.dispatchRawDelta(delta)) - else Offset.Zero + return if (delta > 0 && source == NestedScrollSource.UserInput) { + val cardConsumed = state.dispatchRawDelta(delta) + if (cardConsumed != 0f) lastGestureWasExpand = false + Offset(0f, cardConsumed) + } else Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset + if (currentOffset <= 0f || currentOffset >= collapsedOffsetPx) return Velocity.Zero + + return when { + available.y > 0f -> { + // Downward fling: snap to COLLAPSED + state.settle(available.y) + available + } + available.y < 0f || lastGestureWasExpand -> { + // Upward fling OR last drag was expanding: snap to EXPANDED + state.settle(-1000f) + available + } + else -> { + // Slow stop after a collapse drag: settle by positional threshold + state.settle(0f) + Velocity.Zero + } + } } } } @@ -126,14 +218,16 @@ fun ImmersiveDetailScaffold( ) // Layer 2: Card + val cardShape = RoundedCornerShape(topStart = topCornerRadiusDp, topEnd = topCornerRadiusDp) Column( modifier = Modifier .offset { IntOffset(0, cardOffsetPx.roundToInt()) } .fillMaxWidth() .height(screenHeight) .nestedScroll(nestedScrollConnection) - .anchoredDraggable(state, Orientation.Vertical) - .clip(RoundedCornerShape(topStart = topCornerRadiusDp, topEnd = topCornerRadiusDp)) + .anchoredDraggable(state, Orientation.Vertical, enabled = innerScrollPx <= 0f) + .shadow(elevation = 2.dp, shape = cardShape) + .clip(cardShape) .background(backgroundColor) ) { Box( @@ -155,33 +249,43 @@ fun ImmersiveDetailScaffold( // Layer 3: Thumbnail — fades in as card expands, moves with the card // Positioned at card top + drag handle (28dp) + small gap (8dp), left-aligned with 16dp margin val thumbAlpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) + // Clip container: top edge sits at status bar bottom; clipToBounds hides thumbnail above it Box( modifier = Modifier - .offset { - IntOffset( - x = with(density) { 16.dp.toPx() }.roundToInt(), - y = (cardOffsetPx + with(density) { (28.dp + 20.dp).toPx() }).roundToInt() - ) - } - .graphicsLayer { alpha = thumbAlpha } + .fillMaxWidth() + .height(screenHeight - statusBarDp) + .offset { IntOffset(0, statusBarPx.roundToInt()) } + .clipToBounds() ) { - ThumbnailImage( - data = coverData, - cacheKey = coverKey, - contentScale = ContentScale.Crop, + Box( modifier = Modifier - .size(width = 110.dp, height = (110.dp / 0.703f)) - .clip(RoundedCornerShape(8.dp)) - ) + .offset { + IntOffset( + x = with(density) { 16.dp.toPx() }.roundToInt(), + y = (cardOffsetPx + with(density) { (28.dp + 20.dp).toPx() } - innerScrollPx - statusBarPx) + .roundToInt() + ) + } + .graphicsLayer { alpha = thumbAlpha } + ) { + ThumbnailImage( + data = coverData, + cacheKey = coverKey, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = (110.dp / 0.703f)) + .clip(RoundedCornerShape(8.dp)) + ) + } } - // Layer 4: FAB - val fabAlpha = (1f - expandFraction * 3f).coerceIn(0f, 1f) + // Layer 4: FAB — fixed at bottom, always visible, above system nav bar Box( modifier = Modifier - .offset { IntOffset(0, (cardOffsetPx - with(density) { 72.dp.toPx() }).roundToInt()) } + .align(Alignment.BottomCenter) .fillMaxWidth() - .graphicsLayer { alpha = fabAlpha } + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) ) { fabContent() } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt index 643519b2..3508170d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -55,6 +54,7 @@ import snd.komelia.image.coil.SeriesDefaultThumbnailRequest import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalPlatform import snd.komelia.ui.LocalUseNewLibraryUI +import snd.komelia.ui.common.immersive.ImmersiveDetailFab import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold import snd.komelia.ui.platform.PlatformType @@ -119,12 +119,12 @@ class OneshotScreen( } }, fabContent = { - Button( - onClick = {}, - modifier = Modifier.padding(horizontal = 16.dp) - ) { - Text("Boilerplate FAB") - } + ImmersiveDetailFab( + onReadClick = {}, + onReadIncognitoClick = {}, + onDownloadClick = {}, + accentColor = LocalAccentColor.current, + ) }, cardContent = { expandFraction -> Column( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt index 54921cc4..91487cbd 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt @@ -30,31 +30,10 @@ import snd.komga.client.series.KomgaSeries import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import snd.komelia.image.coil.SeriesDefaultThumbnailRequest import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalPlatform import snd.komelia.ui.LocalUseNewLibraryUI -import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.series.immersive.ImmersiveSeriesContent import snd.komelia.ui.platform.PlatformType fun seriesScreen(series: KomgaSeries): Screen = @@ -102,56 +81,35 @@ class SeriesScreen( val platform = LocalPlatform.current val useNewUI = LocalUseNewLibraryUI.current - if (platform == PlatformType.MOBILE && useNewUI) { - ImmersiveDetailScaffold( - coverData = SeriesDefaultThumbnailRequest(seriesId), - coverKey = "series-$seriesId", - cardColor = LocalAccentColor.current, - immersive = true, - topBarContent = { - Box( - modifier = Modifier - .padding(start = 12.dp, top = 8.dp) - .size(36.dp) - .background(Color.Black.copy(alpha = 0.55f), CircleShape) - .clickable { onBackPress(navigator, vm.series.value?.libraryId) }, - contentAlignment = Alignment.Center - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) - } + val series = vm.series.collectAsState().value + if (platform == PlatformType.MOBILE && useNewUI && series != null) { + ImmersiveSeriesContent( + series = series, + library = vm.library.collectAsState().value, + accentColor = LocalAccentColor.current, + onLibraryClick = { navigator.push(LibraryScreen(it.id)) }, + seriesMenuActions = vm.seriesMenuActions(), + onFilterClick = { filter -> navigator.push(LibraryScreen(series.libraryId, filter)) }, + currentTab = vm.currentTab, + onTabChange = vm::onTabChange, + booksState = vm.booksState, + onBookClick = { navigator push bookScreen(it) }, + onBookReadClick = { book, markProgress -> + navigator.parent?.push(readerScreen(book, markProgress)) }, - fabContent = { - Button( - onClick = {}, - modifier = Modifier.padding(horizontal = 16.dp) - ) { - Text("Boilerplate FAB") - } + collectionsState = vm.collectionsState, + onCollectionClick = { navigator.push(CollectionScreen(it.id)) }, + onSeriesClick = { s -> + navigator.push( + if (s.oneshot) OneshotScreen(s, BookSiblingsContext.Series) + else SeriesScreen(s, vm.currentTab) + ) }, - cardContent = { expandFraction -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .padding(start = (126.dp * expandFraction).coerceAtLeast(0.dp)) - ) { - Text( - text = vm.series.collectAsState().value?.metadata?.title ?: "Loading...", - style = MaterialTheme.typography.titleLarge - ) - Spacer(Modifier.height(16.dp)) - Text("Immersive Detail Boilerplate") - Text("Scroll anywhere on the card to see the cover shrink animation.") - - // Add some height to enable scrolling/dragging if needed - Spacer(Modifier.height(1000.dp)) - } - } + onBackClick = { onBackPress(navigator, series.libraryId) }, + onDownload = vm::onDownload, ) - BackPressHandler { - vm.series.value?.let { onBackPress(navigator, it.libraryId) } - } + BackPressHandler { onBackPress(navigator, series.libraryId) } return } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt new file mode 100644 index 00000000..5a1eefbc --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt @@ -0,0 +1,407 @@ +package snd.komelia.ui.series.immersive + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import snd.komelia.komga.api.model.KomeliaBook +import snd.komelia.ui.LoadState +import snd.komelia.ui.collection.SeriesCollectionsContent +import snd.komelia.ui.collection.SeriesCollectionsState +import snd.komelia.ui.common.components.AppFilterChipDefaults +import snd.komelia.ui.common.immersive.ImmersiveDetailFab +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.common.menus.SeriesActionsMenu +import snd.komelia.ui.common.menus.SeriesMenuActions +import snd.komelia.ui.common.menus.bulk.BooksBulkActionsContent +import snd.komelia.ui.common.menus.bulk.BottomPopupBulkActionsPanel +import snd.komelia.ui.common.menus.bulk.BulkActionsContainer +import snd.komelia.ui.dialogs.ConfirmationDialog +import snd.komelia.ui.dialogs.permissions.DownloadNotificationRequestDialog +import snd.komelia.ui.library.SeriesScreenFilter +import snd.komelia.ui.series.SeriesBooksState +import snd.komelia.ui.series.SeriesBooksState.BooksData +import snd.komelia.ui.series.SeriesViewModel.SeriesTab +import snd.komelia.ui.series.view.SeriesBooksContent +import snd.komelia.ui.series.view.SeriesChipTags +import snd.komelia.ui.series.view.SeriesDescriptionRow +import snd.komelia.ui.series.view.SeriesSummary +import snd.komga.client.collection.KomgaCollection +import snd.komga.client.library.KomgaLibrary +import snd.komga.client.series.KomgaSeries + +private enum class ImmersiveTab { BOOKS, COLLECTIONS, TAGS } + +@Composable +fun ImmersiveSeriesContent( + series: KomgaSeries, + library: KomgaLibrary?, + accentColor: Color?, + onLibraryClick: (KomgaLibrary) -> Unit, + seriesMenuActions: SeriesMenuActions, + onFilterClick: (SeriesScreenFilter) -> Unit, + currentTab: SeriesTab, + onTabChange: (SeriesTab) -> Unit, + booksState: SeriesBooksState, + onBookClick: (KomeliaBook) -> Unit, + onBookReadClick: (KomeliaBook, Boolean) -> Unit, + collectionsState: SeriesCollectionsState, + onCollectionClick: (KomgaCollection) -> Unit, + onSeriesClick: (KomgaSeries) -> Unit, + onBackClick: () -> Unit, + onDownload: () -> Unit, +) { + val booksLoadState = booksState.state.collectAsState().value + val booksData = remember(booksLoadState) { + if (booksLoadState is LoadState.Success) booksLoadState.value else BooksData() + } + val bookMenuActions = remember { booksState.bookMenuActions() } + val bookBulkActions = remember { booksState.bookBulkMenuActions() } + val gridMinWidth = booksState.cardWidth.collectAsState().value + val scrollState = rememberLazyGridState() + + val selectionMode = booksData.selectionMode + val selectedBooks = booksData.selectedBooks + + // First unread book — used for Read Now action + val firstUnreadBook = remember(booksData.books) { + booksData.books.firstOrNull { it.readProgress == null || it.readProgress?.completed == false } + ?: booksData.books.firstOrNull() + } + + var showDownloadConfirmationDialog by remember { mutableStateOf(false) } + + // Local tab state — includes TAGS which has no VM counterpart + var immersiveTab by remember { + mutableStateOf( + when (currentTab) { + SeriesTab.BOOKS -> ImmersiveTab.BOOKS + SeriesTab.COLLECTIONS -> ImmersiveTab.COLLECTIONS + } + ) + } + + val onImmersiveTabChange: (ImmersiveTab) -> Unit = { tab -> + immersiveTab = tab + when (tab) { + ImmersiveTab.BOOKS -> onTabChange(SeriesTab.BOOKS) + ImmersiveTab.COLLECTIONS -> onTabChange(SeriesTab.COLLECTIONS) + ImmersiveTab.TAGS -> Unit + } + } + + // Keep in sync if something external changes the VM tab + LaunchedEffect(currentTab) { + if (immersiveTab != ImmersiveTab.TAGS) { + immersiveTab = when (currentTab) { + SeriesTab.BOOKS -> ImmersiveTab.BOOKS + SeriesTab.COLLECTIONS -> ImmersiveTab.COLLECTIONS + } + } + } + + ImmersiveDetailScaffold( + coverData = SeriesDefaultThumbnailRequest(series.id), + coverKey = series.id.value, + cardColor = accentColor, + immersive = true, + topBarContent = { + if (selectionMode) { + BulkActionsContainer( + onCancel = { booksState.setSelectionMode(false) }, + selectedCount = selectedBooks.size, + allSelected = booksData.books.size == selectedBooks.size, + onSelectAll = { + val allSelected = booksData.books.size == selectedBooks.size + if (allSelected) booksData.books.forEach { booksState.onBookSelect(it) } + else booksData.books + .filter { book -> selectedBooks.none { it.id == book.id } } + .forEach { booksState.onBookSelect(it) } + } + ) {} + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 4.dp, top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable(onClick = onBackClick), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = Color.White + ) + } + + var expandActions by remember { mutableStateOf(false) } + Box { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { expandActions = true }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White) + } + SeriesActionsMenu( + series = series, + actions = seriesMenuActions, + expanded = expandActions, + showEditOption = true, + showDownloadOption = false, + onDismissRequest = { expandActions = false }, + ) + } + } + } + }, + fabContent = { + ImmersiveDetailFab( + onReadClick = { firstUnreadBook?.let { onBookReadClick(it, true) } }, + onReadIncognitoClick = { firstUnreadBook?.let { onBookReadClick(it, false) } }, + onDownloadClick = { showDownloadConfirmationDialog = true }, + accentColor = accentColor, + showReadActions = false, + ) + }, + cardContent = { expandFraction -> + val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) + + // Thumbnail metrics — must match ImmersiveDetailScaffold Layer 3 + val thumbnailTopGap = 20.dp + val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp + + LazyVerticalGrid( + state = scrollState, + columns = GridCells.Adaptive(gridMinWidth), + horizontalArrangement = Arrangement.spacedBy(15.dp), + modifier = Modifier.fillMaxWidth(), + ) { + // Title + writers in a single item whose minimum height equals the thumbnail + // bottom when expanded — this pushes the description row below the thumbnail, + // avoiding Z-order overlap, while still scrolling with the rest of the content. + item(span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) + .padding( + start = thumbnailOffset + 16.dp, + end = 16.dp, + top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, + ) + ) { + Column { + Text( + text = series.metadata.title, + style = MaterialTheme.typography.headlineMedium.copy( + fontSize = (MaterialTheme.typography.headlineMedium.fontSize.value * 2f / 3f).sp, + fontWeight = FontWeight.Bold, + ), + ) + val writers = remember(series.booksMetadata.authors) { + series.booksMetadata.authors + .filter { it.role.lowercase() == "writer" } + .joinToString(", ") { it.name } + } + val year = series.booksMetadata.releaseDate?.year + val writersYearText = buildString { + if (writers.isNotEmpty()) append(writers) + if (year != null) { if (writers.isNotEmpty()) append(" "); append("($year)") } + } + if (writersYearText.isNotEmpty()) { + Text( + text = writersYearText, + fontSize = 10.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + + // Description row (library, status, age rating, etc.) — full width + if (library != null) { + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesDescriptionRow( + library = library, + onLibraryClick = onLibraryClick, + releaseDate = series.booksMetadata.releaseDate, + status = series.metadata.status, + ageRating = series.metadata.ageRating, + language = series.metadata.language, + readingDirection = series.metadata.readingDirection, + deleted = series.deleted || library.unavailable, + alternateTitles = series.metadata.alternateTitles, + onFilterClick = onFilterClick, + showReleaseYear = false, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + // Summary — full width + item(span = { GridItemSpan(maxLineSpan) }) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + SeriesSummary( + seriesSummary = series.metadata.summary, + bookSummary = series.booksMetadata.summary, + bookSummaryNumber = series.booksMetadata.summaryNumber, + ) + } + } + + // Divider + item(span = { GridItemSpan(maxLineSpan) }) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + + // Tab row + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesImmersiveTabRow( + currentTab = immersiveTab, + onTabChange = onImmersiveTabChange, + showCollectionsTab = collectionsState.collections.isNotEmpty(), + ) + } + + // Tab content + when (immersiveTab) { + ImmersiveTab.BOOKS -> SeriesBooksContent( + series = series, + onBookClick = onBookClick, + onBookReadClick = onBookReadClick, + scrollState = scrollState, + booksLoadState = booksLoadState, + onBooksLayoutChange = booksState::onBookLayoutChange, + onBooksPageSizeChange = booksState::onBookPageSizeChange, + onPageChange = booksState::onPageChange, + onBookSelect = booksState::onBookSelect, + booksFilterState = booksState.filterState, + bookContextMenuActions = bookMenuActions, + ) + + ImmersiveTab.COLLECTIONS -> item(span = { GridItemSpan(maxLineSpan) }) { + SeriesCollectionsContent( + collections = collectionsState.collections, + onCollectionClick = onCollectionClick, + onSeriesClick = onSeriesClick, + cardWidth = collectionsState.cardWidth.collectAsState().value, + ) + } + + ImmersiveTab.TAGS -> item(span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.padding(horizontal = 16.dp)) { + SeriesChipTags(series = series, onFilterClick = onFilterClick) + } + } + } + } + } + ) + + if (showDownloadConfirmationDialog) { + var permissionRequested by remember { mutableStateOf(false) } + DownloadNotificationRequestDialog { permissionRequested = true } + if (permissionRequested) { + ConfirmationDialog( + body = "Download series \"${series.metadata.title}\"?", + onDialogConfirm = { + onDownload() + showDownloadConfirmationDialog = false + }, + onDialogDismiss = { showDownloadConfirmationDialog = false } + ) + } + } + + if (selectionMode && selectedBooks.isNotEmpty()) { + BottomPopupBulkActionsPanel { + BooksBulkActionsContent( + books = selectedBooks, + actions = bookBulkActions, + compact = true + ) + } + } +} + +@Composable +private fun SeriesImmersiveTabRow( + currentTab: ImmersiveTab, + onTabChange: (ImmersiveTab) -> Unit, + showCollectionsTab: Boolean, +) { + val chipColors = AppFilterChipDefaults.filterChipColors() + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + FilterChip( + onClick = { onTabChange(ImmersiveTab.BOOKS) }, + selected = currentTab == ImmersiveTab.BOOKS, + label = { Text("Books") }, + colors = chipColors, + border = null, + ) + if (showCollectionsTab) { + FilterChip( + onClick = { onTabChange(ImmersiveTab.COLLECTIONS) }, + selected = currentTab == ImmersiveTab.COLLECTIONS, + label = { Text("Collections") }, + colors = chipColors, + border = null, + ) + } + FilterChip( + onClick = { onTabChange(ImmersiveTab.TAGS) }, + selected = currentTab == ImmersiveTab.TAGS, + label = { Text("Tags") }, + colors = chipColors, + border = null, + ) + } + HorizontalDivider() + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt index 02502351..9b7b20c9 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt @@ -53,6 +53,7 @@ fun SeriesDescriptionRow( deleted: Boolean, alternateTitles: List, onFilterClick: (SeriesScreenFilter) -> Unit, + showReleaseYear: Boolean = true, modifier: Modifier ) { val strings = LocalStrings.current.seriesView @@ -62,7 +63,7 @@ fun SeriesDescriptionRow( horizontalAlignment = Alignment.Start ) { - if (releaseDate != null) + if (showReleaseYear && releaseDate != null) Text("Release Year: ${releaseDate.year}", fontSize = 10.sp) FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { From 4b3324f5dbaeed27db17396b05bd10af91beaefc Mon Sep 17 00:00:00 2001 From: Eyal Date: Wed, 25 Feb 2026 00:53:57 +0200 Subject: [PATCH 04/35] New library UI: ImmersiveBookContent + ImmersiveOneshotContent + scaffold pager sync - Extract ImmersiveBookContent and ImmersiveOneshotContent composables from BookScreen/OneshotScreen; screens now delegate to these dedicated composables - ImmersiveDetailScaffold: add initiallyExpanded param + onExpandChange callback for synchronized expand/collapse state across pager pages; use remember instead of rememberSaveable so adjacent pager pages don't restore stale state; snapTo correct anchor when parent-driven initiallyExpanded changes - ImmersiveSeriesContent: add nav bar + 80 dp bottom content padding to grid so last row isn't hidden behind nav bar - Minor import cleanup in BookScreen / OneshotScreen Co-Authored-By: Claude Sonnet 4.6 --- .../commonMain/kotlin/snd/komelia/ui/Theme.kt | 10 +- .../kotlin/snd/komelia/ui/book/BookScreen.kt | 94 ++--- .../snd/komelia/ui/book/BookViewModel.kt | 18 + .../ui/book/immersive/ImmersiveBookContent.kt | 388 ++++++++++++++++++ .../ui/common/images/ThumbnailImage.kt | 3 +- .../immersive/ImmersiveDetailScaffold.kt | 20 +- .../komelia/ui/common/itemlist/SeriesLists.kt | 11 +- .../snd/komelia/ui/oneshot/OneshotScreen.kt | 106 ++--- .../immersive/ImmersiveOneshotContent.kt | 352 ++++++++++++++++ .../immersive/ImmersiveSeriesContent.kt | 8 + 10 files changed, 868 insertions(+), 142 deletions(-) create mode 100644 komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt create mode 100644 komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/Theme.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/Theme.kt index 8dab4e04..ae0d82f6 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/Theme.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/Theme.kt @@ -65,17 +65,17 @@ enum class Theme( tertiaryContainer = Color(red = 181, green = 130, blue = 49), onTertiaryContainer = Color.White, - background = Color(red = 254, green = 247, blue = 255), + background = Color.White, // Original: Color(red = 254, green = 247, blue = 255) onBackground = Color(red = 29, green = 27, blue = 32), - surface = Color(red = 254, green = 247, blue = 255), + surface = Color.White, // Original: Color(red = 254, green = 247, blue = 255) onSurface = Color(red = 29, green = 27, blue = 32), - surfaceVariant = Color(red = 231, green = 224, blue = 236), - surfaceContainerHighest = Color(red = 230, green = 224, blue = 233), + surfaceVariant = Color(red = 240, green = 240, blue = 240), // Original: Color(red = 231, green = 224, blue = 236) + surfaceContainerHighest = Color(red = 235, green = 235, blue = 235), // Original: Color(red = 230, green = 224, blue = 233) onSurfaceVariant = Color(red = 73, green = 69, blue = 79), - surfaceDim = Color(red = 222, green = 216, blue = 225), + surfaceDim = Color(red = 225, green = 225, blue = 225), // Original: Color(red = 222, green = 216, blue = 225) surfaceBright = Color(red = 180, green = 180, blue = 180), error = Color(red = 240, green = 70, blue = 60), diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt index 9b29b538..f1d0828d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt @@ -27,31 +27,10 @@ import snd.komga.client.book.KomgaBookId import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import snd.komelia.image.coil.BookDefaultThumbnailRequest import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalPlatform import snd.komelia.ui.LocalUseNewLibraryUI -import snd.komelia.ui.common.immersive.ImmersiveDetailFab -import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.book.immersive.ImmersiveBookContent import snd.komelia.ui.platform.PlatformType fun bookScreen( @@ -96,53 +75,38 @@ class BookScreen( val platform = LocalPlatform.current val useNewUI = LocalUseNewLibraryUI.current if (platform == PlatformType.MOBILE && useNewUI) { - ImmersiveDetailScaffold( - coverData = BookDefaultThumbnailRequest(bookId), - coverKey = "book-$bookId", - cardColor = LocalAccentColor.current, - immersive = true, - topBarContent = { - Box( - modifier = Modifier - .padding(start = 12.dp, top = 8.dp) - .size(36.dp) - .background(Color.Black.copy(alpha = 0.55f), CircleShape) - .clickable { onBackPress(navigator, vm.book.value?.seriesId) }, - contentAlignment = Alignment.Center - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) - } - }, - fabContent = { - ImmersiveDetailFab( - onReadClick = {}, - onReadIncognitoClick = {}, - onDownloadClick = {}, - accentColor = LocalAccentColor.current, + val book = vm.book.collectAsState().value ?: return + val siblings = vm.siblingBooks.collectAsState().value + + ImmersiveBookContent( + book = book, + siblingBooks = siblings, + accentColor = LocalAccentColor.current, + bookMenuActions = vm.bookMenuActions, + onBackClick = { onBackPress(navigator, book.seriesId) }, + onReadBook = { selectedBook, markReadProgress -> + navigator.parent?.push( + readerScreen(selectedBook, markReadProgress, bookSiblingsContext) ) }, - cardContent = { expandFraction -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .padding(start = (126.dp * expandFraction).coerceAtLeast(0.dp)) - ) { - Text( - text = vm.book.collectAsState().value?.metadata?.title ?: "Loading...", - style = MaterialTheme.typography.titleLarge + onDownload = vm::onBookDownload, + onFilterClick = { filter -> + navigator.push(LibraryScreen(book.libraryId, filter)) + }, + readLists = vm.readListsState.readLists, + onReadListClick = { navigator.push(ReadListScreen(it.id)) }, + onReadListBookPress = { book, readList -> + if (book.id != bookId) navigator.push( + bookScreen( + book = book, + bookSiblingsContext = BookSiblingsContext.ReadList(readList.id) ) - Spacer(Modifier.height(16.dp)) - Text("Immersive Book Boilerplate") - Text("Scroll anywhere on the card to see the cover shrink animation.") - - // Add some height to enable scrolling/dragging if needed - Spacer(Modifier.height(1000.dp)) - } - } + ) + }, + cardWidth = vm.cardWidth.collectAsState().value, + onSeriesClick = { seriesId -> navigator.push(SeriesScreen(seriesId)) }, ) - - BackPressHandler { onBackPress(navigator, vm.book.value?.seriesId) } + BackPressHandler { onBackPress(navigator, book.seriesId) } return } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt index 81addd81..6cba5963 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt @@ -33,7 +33,9 @@ import snd.komelia.ui.common.cards.defaultCardWidth import snd.komelia.ui.common.menus.BookMenuActions import snd.komelia.ui.readlist.BookReadListsState import snd.komga.client.book.KomgaBookId +import snd.komga.client.common.KomgaPageRequest import snd.komga.client.library.KomgaLibrary +import snd.komga.client.search.allOfBooks import snd.komga.client.sse.KomgaEvent import snd.komga.client.sse.KomgaEvent.BookAdded import snd.komga.client.sse.KomgaEvent.BookChanged @@ -70,6 +72,8 @@ class BookViewModel( val cardWidth = settingsRepository.getCardWidth().map { it.dp } .stateIn(screenModelScope, Eagerly, defaultCardWidth.dp) + val siblingBooks = MutableStateFlow>(emptyList()) + val bookMenuActions = BookMenuActions(bookApi, notifications, screenModelScope, taskEmitter) suspend fun initialize() { @@ -79,6 +83,7 @@ class BookViewModel( else mutableState.value = Success(Unit) loadLibrary() readListsState.initialize() + loadSiblingBooks() startKomgaEventListener() reloadJobsFlow.onEach { @@ -95,6 +100,19 @@ class BookViewModel( } } + fun loadSiblingBooks() { + screenModelScope.launch { + val seriesId = book.value?.seriesId ?: return@launch + notifications.runCatchingToNotifications { + val page = bookApi.getBookList( + conditionBuilder = allOfBooks { seriesId { isEqualTo(seriesId) } }, + pageRequest = KomgaPageRequest(unpaged = true) + ) + siblingBooks.value = page.content + } + } + } + private suspend fun loadBook() { notifications.runCatchingToNotifications { mutableState.value = Loading diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt new file mode 100644 index 00000000..3b3e1a03 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -0,0 +1,388 @@ +package snd.komelia.ui.book.immersive + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime +import snd.komelia.DefaultDateTimeFormats.localDateTimeFormat +import snd.komelia.image.coil.BookDefaultThumbnailRequest +import snd.komelia.komga.api.model.KomeliaBook +import snd.komelia.ui.book.BookInfoColumn +import snd.komelia.ui.common.immersive.ImmersiveDetailFab +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.common.menus.BookActionsMenu +import snd.komelia.ui.common.menus.BookMenuActions +import snd.komelia.ui.dialogs.ConfirmationDialog +import snd.komelia.ui.dialogs.permissions.DownloadNotificationRequestDialog +import snd.komelia.ui.library.SeriesScreenFilter +import snd.komelia.ui.readlist.BookReadListsContent +import snd.komga.client.readlist.KomgaReadList +import snd.komga.client.series.KomgaSeriesId +import kotlin.math.roundToInt + +@Composable +fun ImmersiveBookContent( + book: KomeliaBook, + siblingBooks: List, + accentColor: Color?, + bookMenuActions: BookMenuActions, + onBackClick: () -> Unit, + onReadBook: (KomeliaBook, Boolean) -> Unit, + onDownload: () -> Unit, + onFilterClick: (SeriesScreenFilter) -> Unit, + readLists: Map>, + onReadListClick: (KomgaReadList) -> Unit, + onReadListBookPress: (KomeliaBook, KomgaReadList) -> Unit, + cardWidth: Dp, + onSeriesClick: (KomgaSeriesId) -> Unit, +) { + val initialPage = remember(siblingBooks, book) { + siblingBooks.indexOfFirst { it.id == book.id }.coerceAtLeast(0) + } + val pagerState = rememberPagerState( + initialPage = initialPage, + pageCount = { maxOf(1, siblingBooks.size) } + ) + + // Once siblings load, jump to the correct page without animation. + // Guard with initialScrollDone so that subsequent siblingBooks emissions + // (e.g. from read-progress updates) don't yank the pager back. + var initialScrollDone by remember { mutableStateOf(false) } + LaunchedEffect(siblingBooks) { + if (!initialScrollDone && siblingBooks.isNotEmpty()) { + val idx = siblingBooks.indexOfFirst { it.id == book.id }.coerceAtLeast(0) + pagerState.scrollToPage(idx) + initialScrollDone = true + } + } + + // selectedBook drives the FAB and 3-dot menu after each swipe settles + val selectedBook = remember(pagerState.settledPage, siblingBooks) { + siblingBooks.getOrNull(pagerState.settledPage) ?: book + } + + var showDownloadConfirmationDialog by remember { mutableStateOf(false) } + var sharedExpanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize()) { + + // Outer HorizontalPager — slides the entire scaffold (cover + card) laterally + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + pageSpacing = 8.dp, + ) { pageIndex -> + val pageBook = siblingBooks.getOrNull(pageIndex) ?: book + // Memoize to avoid a new Random requestCache on every recomposition, which would + // cause ThumbnailImage's remember(data,cacheKey) to rebuild the ImageRequest and flash. + val coverData = remember(pageBook.id) { BookDefaultThumbnailRequest(pageBook.id) } + + ImmersiveDetailScaffold( + coverData = coverData, + coverKey = pageBook.id.value, + cardColor = accentColor, + immersive = true, + initiallyExpanded = sharedExpanded, + onExpandChange = { sharedExpanded = it }, + topBarContent = {}, // Fixed overlay handles this + fabContent = {}, // Fixed overlay handles this + cardContent = { expandFraction -> + val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) + val thumbnailTopGap = 20.dp + val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp + + val navBarBottom = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + LazyVerticalGrid( + columns = GridCells.Fixed(1), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(bottom = navBarBottom + 80.dp), + ) { + // Collapsed stats line (fades out as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (1f - expandFraction * 2f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(pageBook, Modifier + .padding(start = 16.dp, end = 16.dp, top = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // Header: thumbnail offset + series title · #N, book title, writers (year) + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) + .padding( + start = thumbnailOffset + 16.dp, + end = 16.dp, + top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, + ) + ) { + Column { + val headlineFs = MaterialTheme.typography.headlineMedium.fontSize.value + // Line 1: Series · #N (2/3 headlineMedium, bold) — tappable link + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { onSeriesClick(pageBook.seriesId) } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "${pageBook.seriesTitle} · #${pageBook.metadata.number}", + style = MaterialTheme.typography.headlineMedium.copy( + fontSize = (headlineFs * 2f / 3f).sp, + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.primary, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + ) + } + // Line 2: Book title (bodySmall) — only if different from series title + if (pageBook.metadata.title != pageBook.seriesTitle) { + Text( + text = pageBook.metadata.title, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 2.dp), + ) + } + // Line 3: Writers (year) — 10 sp + val writers = remember(pageBook.metadata.authors) { + pageBook.metadata.authors + .filter { it.role.lowercase() == "writer" } + .joinToString(", ") { it.name } + } + val year = pageBook.metadata.releaseDate?.year + val writersYearText = buildString { + if (writers.isNotEmpty()) append(writers) + if (year != null) { + if (writers.isNotEmpty()) append(" ") + append("($year)") + } + } + if (writersYearText.isNotEmpty()) { + Text( + text = writersYearText, + fontSize = 10.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + + // Expanded stats line (fades in as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(pageBook, Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // Summary + if (pageBook.metadata.summary.isNotBlank()) { + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = pageBook.metadata.summary, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Divider + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + + // Book metadata (authors, tags, links, file info, ISBN) + item { + Box(Modifier.padding(horizontal = 16.dp)) { + BookInfoColumn( + publisher = null, + genres = null, + authors = pageBook.metadata.authors, + tags = pageBook.metadata.tags, + links = pageBook.metadata.links, + sizeInMiB = pageBook.size, + mediaType = pageBook.media.mediaType, + isbn = pageBook.metadata.isbn, + fileUrl = pageBook.url, + onFilterClick = onFilterClick, + ) + } + } + + // Reading lists + item(span = { GridItemSpan(maxLineSpan) }) { + BookReadListsContent( + readLists = readLists, + onReadListClick = onReadListClick, + onBookClick = onReadListBookPress, + cardWidth = cardWidth, + ) + } + } + } + ) + } + + // Fixed overlay: back button + 3-dot menu (stays still while pager slides) + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(start = 12.dp, end = 4.dp, top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable(onClick = onBackClick), + contentAlignment = Alignment.Center + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) + } + + var expandActions by remember { mutableStateOf(false) } + Box { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { expandActions = true }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White) + } + BookActionsMenu( + book = selectedBook, + actions = bookMenuActions, + expanded = expandActions, + showEditOption = true, + showDownloadOption = false, // download is in FAB + onDismissRequest = { expandActions = false }, + ) + } + } + + // Fixed overlay: FAB (stays still while pager slides) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) + ) { + ImmersiveDetailFab( + onReadClick = { onReadBook(selectedBook, true) }, + onReadIncognitoClick = { onReadBook(selectedBook, false) }, + onDownloadClick = { showDownloadConfirmationDialog = true }, + accentColor = accentColor, + showReadActions = true, + ) + } + } + + // Two-step download confirmation dialog + if (showDownloadConfirmationDialog) { + var permissionRequested by remember { mutableStateOf(false) } + DownloadNotificationRequestDialog { permissionRequested = true } + if (permissionRequested) { + ConfirmationDialog( + body = "Download \"${selectedBook.metadata.title}\"?", + onDialogConfirm = { + onDownload() + showDownloadConfirmationDialog = false + }, + onDialogDismiss = { showDownloadConfirmationDialog = false }, + ) + } + } +} + +@Composable +private fun BookStatsLine(book: KomeliaBook, modifier: Modifier = Modifier) { + val pagesCount = book.media.pagesCount + val segments = remember(book) { + buildList { + add("$pagesCount page${if (pagesCount == 1) "" else "s"}") + book.metadata.releaseDate?.let { add(it.toString()) } + book.readProgress?.let { progress -> + if (!progress.completed) { + val pagesLeft = pagesCount - progress.page + val pct = (progress.page.toFloat() / pagesCount * 100).roundToInt() + add("$pct%, $pagesLeft page${if (pagesLeft == 1) "" else "s"} left") + } + add(progress.readDate + .toLocalDateTime(TimeZone.currentSystemDefault()) + .format(localDateTimeFormat)) + } + } + } + if (segments.isEmpty()) return + Text( + text = segments.joinToString(" | "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt index 8e15c77c..1d56fd16 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt @@ -19,6 +19,7 @@ fun ThumbnailImage( data: Any, cacheKey: String, contentScale: ContentScale = ContentScale.Fit, + crossfade: Boolean = true, placeholder: Painter? = NoopPainter, modifier: Modifier = Modifier, ) { @@ -37,7 +38,7 @@ fun ThumbnailImage( ) .diskCacheKey(cacheKey) .precision(Precision.EXACT) - .crossfade(true) + .crossfade(crossfade) .build() } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index f540bb16..326ee583 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.snapTo import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.anchoredDraggable @@ -94,6 +95,8 @@ fun ImmersiveDetailScaffold( cardColor: Color?, modifier: Modifier = Modifier, immersive: Boolean = false, + initiallyExpanded: Boolean = false, + onExpandChange: (Boolean) -> Unit = {}, topBarContent: @Composable () -> Unit, fabContent: @Composable () -> Unit, cardContent: @Composable ColumnScope.(expandFraction: Float) -> Unit, @@ -106,8 +109,8 @@ fun ImmersiveDetailScaffold( val collapsedOffset = screenHeight * 0.65f val collapsedOffsetPx = with(density) { collapsedOffset.toPx() } - // Persist expanded/collapsed across back-navigation - var savedExpanded by rememberSaveable { mutableStateOf(false) } + // Use remember (not rememberSaveable) so pager pages don't restore stale saved state. + var savedExpanded by remember { mutableStateOf(initiallyExpanded) } val state = remember(collapsedOffsetPx) { AnchoredDraggableState( @@ -129,8 +132,19 @@ fun ImmersiveDetailScaffold( var innerScrollPx by rememberSaveable { mutableFloatStateOf(0f) } + // Snap already-composed pages (e.g. adjacent in a pager) when the parent changes the + // shared expand state. Skips the snap if the card is already in the right position. + LaunchedEffect(initiallyExpanded) { + val target = if (initiallyExpanded) CardDragValue.EXPANDED else CardDragValue.COLLAPSED + if (state.currentValue != target) { + state.snapTo(target) + } + savedExpanded = initiallyExpanded + } + LaunchedEffect(state.currentValue) { savedExpanded = state.currentValue == CardDragValue.EXPANDED + onExpandChange(savedExpanded) if (state.currentValue == CardDragValue.COLLAPSED) innerScrollPx = 0f } @@ -203,6 +217,7 @@ fun ImmersiveDetailScaffold( ThumbnailImage( data = coverData, cacheKey = coverKey, + crossfade = false, contentScale = ContentScale.Crop, modifier = if (immersive) Modifier @@ -271,6 +286,7 @@ fun ImmersiveDetailScaffold( ThumbnailImage( data = coverData, cacheKey = coverKey, + crossfade = false, contentScale = ContentScale.Crop, modifier = Modifier .size(width = 110.dp, height = (110.dp / 0.703f)) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt index 96594b18..fd0518ba 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt @@ -6,9 +6,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -27,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -79,13 +82,19 @@ fun SeriesLazyCardGrid( val useNewLibraryUI = LocalUseNewLibraryUI.current val cardSpacing = if (useNewLibraryUI) 7.dp else 15.dp val horizontalPadding = if (useNewLibraryUI) 10.dp else 20.dp + val navBarBottom = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } Box(modifier) { LazyVerticalGrid( state = gridState, columns = GridCells.Adaptive(minSize), horizontalArrangement = Arrangement.spacedBy(cardSpacing), verticalArrangement = Arrangement.spacedBy(cardSpacing), - contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding, bottom = 50.dp), + contentPadding = PaddingValues( + start = horizontalPadding, end = horizontalPadding, + bottom = navBarBottom + 65.dp, + ), ) { item(span = { GridItemSpan(maxLineSpan) }) { beforeContent() diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt index 3508170d..52c7be32 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt @@ -31,31 +31,10 @@ import snd.komga.client.series.KomgaSeries import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import snd.komelia.image.coil.SeriesDefaultThumbnailRequest import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalPlatform import snd.komelia.ui.LocalUseNewLibraryUI -import snd.komelia.ui.common.immersive.ImmersiveDetailFab -import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.oneshot.immersive.ImmersiveOneshotContent import snd.komelia.ui.platform.PlatformType class OneshotScreen( @@ -100,56 +79,47 @@ class OneshotScreen( val platform = LocalPlatform.current val useNewUI = LocalUseNewLibraryUI.current - if (platform == PlatformType.MOBILE && useNewUI) { - ImmersiveDetailScaffold( - coverData = SeriesDefaultThumbnailRequest(seriesId), - coverKey = "series-$seriesId", - cardColor = LocalAccentColor.current, - immersive = true, - topBarContent = { - Box( - modifier = Modifier - .padding(start = 12.dp, top = 8.dp) - .size(36.dp) - .background(Color.Black.copy(alpha = 0.55f), CircleShape) - .clickable { onBackPress(navigator, vm.series.value?.libraryId) }, - contentAlignment = Alignment.Center - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) - } + val vmBook = vm.book.collectAsState().value + val vmSeries = vm.series.collectAsState().value + val vmLibrary = vm.library.collectAsState().value + if (platform == PlatformType.MOBILE && useNewUI && vmBook != null && vmSeries != null && vmLibrary != null) { + ImmersiveOneshotContent( + series = vmSeries, + book = vmBook, + library = vmLibrary, + accentColor = LocalAccentColor.current, + onLibraryClick = { navigator.push(LibraryScreen(it.id)) }, + onBookReadClick = { markReadProgress -> + navigator.parent?.push( + readerScreen( + book = vmBook, + markReadProgress = markReadProgress, + bookSiblingsContext = bookSiblingsContext, + ) + ) }, - fabContent = { - ImmersiveDetailFab( - onReadClick = {}, - onReadIncognitoClick = {}, - onDownloadClick = {}, - accentColor = LocalAccentColor.current, + oneshotMenuActions = vm.bookMenuActions, + collections = vm.collectionsState.collections, + onCollectionClick = { collection -> navigator.push(CollectionScreen(collection.id)) }, + onSeriesClick = { navigator.push(seriesScreen(it)) }, + readLists = vm.readListsState.readLists, + onReadListClick = { navigator.push(ReadListScreen(it.id)) }, + onReadlistBookClick = { book, readList -> + navigator push bookScreen( + book = book, + bookSiblingsContext = BookSiblingsContext.ReadList(readList.id) ) }, - cardContent = { expandFraction -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .padding(start = (126.dp * expandFraction).coerceAtLeast(0.dp)) - ) { - Text( - text = vm.series.collectAsState().value?.metadata?.title ?: "Loading...", - style = MaterialTheme.typography.titleLarge - ) - Spacer(Modifier.height(16.dp)) - Text("Immersive Oneshot Boilerplate") - Text("Scroll anywhere on the card to see the cover shrink animation.") - - // Add some height to enable scrolling/dragging if needed - Spacer(Modifier.height(1000.dp)) - } - } + onFilterClick = { filter -> + navigator.popUntilRoot() + navigator.dispose(navigator.lastItem) + navigator.replaceAll(LibraryScreen(vmBook.libraryId, filter)) + }, + onBookDownload = vm::onBookDownload, + cardWidth = vm.cardWidth.collectAsState().value, + onBackClick = { onBackPress(navigator, vmSeries.libraryId) }, ) - - BackPressHandler { - vm.series.value?.let { onBackPress(navigator, it.libraryId) } - } + BackPressHandler { onBackPress(navigator, vmSeries.libraryId) } return } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt new file mode 100644 index 00000000..599aaab5 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -0,0 +1,352 @@ +package snd.komelia.ui.oneshot.immersive + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime +import snd.komelia.DefaultDateTimeFormats.localDateTimeFormat +import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import kotlin.math.roundToInt +import snd.komelia.komga.api.model.KomeliaBook +import snd.komelia.ui.book.BookInfoColumn +import snd.komelia.ui.common.immersive.ImmersiveDetailFab +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.common.menus.BookMenuActions +import snd.komelia.ui.common.menus.OneshotActionsMenu +import snd.komelia.ui.dialogs.ConfirmationDialog +import snd.komelia.ui.dialogs.permissions.DownloadNotificationRequestDialog +import snd.komelia.ui.library.SeriesScreenFilter +import snd.komelia.ui.readlist.BookReadListsContent +import snd.komelia.ui.collection.SeriesCollectionsContent +import snd.komelia.ui.series.view.SeriesDescriptionRow +import snd.komga.client.collection.KomgaCollection +import snd.komga.client.library.KomgaLibrary +import snd.komga.client.readlist.KomgaReadList +import snd.komga.client.series.KomgaSeries + +@Composable +fun ImmersiveOneshotContent( + series: KomgaSeries, + book: KomeliaBook, + library: KomgaLibrary, + accentColor: Color?, + onLibraryClick: (KomgaLibrary) -> Unit, + onBookReadClick: (markReadProgress: Boolean) -> Unit, + oneshotMenuActions: BookMenuActions, + collections: Map>, + onCollectionClick: (KomgaCollection) -> Unit, + onSeriesClick: (KomgaSeries) -> Unit, + readLists: Map>, + onReadListClick: (KomgaReadList) -> Unit, + onReadlistBookClick: (KomeliaBook, KomgaReadList) -> Unit, + onFilterClick: (SeriesScreenFilter) -> Unit, + onBookDownload: () -> Unit, + cardWidth: Dp, + onBackClick: () -> Unit, +) { + var showDownloadConfirmationDialog by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize()) { + + ImmersiveDetailScaffold( + coverData = SeriesDefaultThumbnailRequest(series.id), + coverKey = series.id.value, + cardColor = accentColor, + immersive = true, + topBarContent = {}, // Fixed overlay handles this + fabContent = {}, // Fixed overlay handles this + cardContent = { expandFraction -> + val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) + val thumbnailTopGap = 20.dp + val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp + + val navBarBottom = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + LazyVerticalGrid( + columns = GridCells.Fixed(1), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(bottom = navBarBottom + 80.dp), + ) { + // Collapsed stats line (fades out as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (1f - expandFraction * 2f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(book, Modifier + .padding(start = 16.dp, end = 16.dp, top = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // Header: book title + writers (year) + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) + .padding( + start = thumbnailOffset + 16.dp, + end = 16.dp, + top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, + ) + ) { + Column { + val headlineFs = MaterialTheme.typography.headlineMedium.fontSize.value + // Book title (2/3 headlineMedium, bold) + Text( + text = book.metadata.title, + style = MaterialTheme.typography.headlineMedium.copy( + fontSize = (headlineFs * 2f / 3f).sp, + fontWeight = FontWeight.Bold, + ), + ) + // Writers (year) — 10 sp + val writers = remember(book.metadata.authors) { + book.metadata.authors + .filter { it.role.lowercase() == "writer" } + .joinToString(", ") { it.name } + } + val year = book.metadata.releaseDate?.year + val writersYearText = buildString { + if (writers.isNotEmpty()) append(writers) + if (year != null) { + if (writers.isNotEmpty()) append(" ") + append("($year)") + } + } + if (writersYearText.isNotEmpty()) { + Text( + text = writersYearText, + fontSize = 10.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + + // Expanded stats line (fades in as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(book, Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // SeriesDescriptionRow (library, status, age rating, etc.) + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesDescriptionRow( + library = library, + onLibraryClick = onLibraryClick, + releaseDate = null, + status = null, + ageRating = series.metadata.ageRating, + language = series.metadata.language, + readingDirection = series.metadata.readingDirection, + deleted = series.deleted || library.unavailable, + alternateTitles = series.metadata.alternateTitles, + onFilterClick = onFilterClick, + showReleaseYear = false, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + // Summary + if (book.metadata.summary.isNotBlank()) { + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = book.metadata.summary, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Divider + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + + // Book metadata (authors, tags, links, file info, ISBN) + item { + Box(Modifier.padding(horizontal = 16.dp)) { + BookInfoColumn( + publisher = series.metadata.publisher, + genres = series.metadata.genres, + authors = book.metadata.authors, + tags = book.metadata.tags, + links = book.metadata.links, + sizeInMiB = book.size, + mediaType = book.media.mediaType, + isbn = book.metadata.isbn, + fileUrl = book.url, + onFilterClick = onFilterClick, + ) + } + } + + // Reading lists + item(span = { GridItemSpan(maxLineSpan) }) { + BookReadListsContent( + readLists = readLists, + onReadListClick = onReadListClick, + onBookClick = onReadlistBookClick, + cardWidth = cardWidth, + ) + } + + // Collections + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesCollectionsContent( + collections = collections, + onCollectionClick = onCollectionClick, + onSeriesClick = onSeriesClick, + cardWidth = cardWidth, + ) + } + } + } + ) + + // Fixed overlay: back button + 3-dot menu + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(start = 12.dp, end = 4.dp, top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable(onClick = onBackClick), + contentAlignment = Alignment.Center + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) + } + + var expandActions by remember { mutableStateOf(false) } + Box { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { expandActions = true }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White) + } + OneshotActionsMenu( + series = series, + book = book, + actions = oneshotMenuActions, + expanded = expandActions, + onDismissRequest = { expandActions = false }, + ) + } + } + + // Fixed overlay: FAB + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) + ) { + ImmersiveDetailFab( + onReadClick = { onBookReadClick(true) }, + onReadIncognitoClick = { onBookReadClick(false) }, + onDownloadClick = { showDownloadConfirmationDialog = true }, + accentColor = accentColor, + showReadActions = true, + ) + } + } + + if (showDownloadConfirmationDialog) { + var permissionRequested by remember { mutableStateOf(false) } + DownloadNotificationRequestDialog { permissionRequested = true } + if (permissionRequested) { + ConfirmationDialog( + body = "Download \"${book.metadata.title}\"?", + onDialogConfirm = { + onBookDownload() + showDownloadConfirmationDialog = false + }, + onDialogDismiss = { showDownloadConfirmationDialog = false }, + ) + } + } +} + +@Composable +private fun BookStatsLine(book: KomeliaBook, modifier: Modifier = Modifier) { + val pagesCount = book.media.pagesCount + val segments = remember(book) { + buildList { + add("$pagesCount page${if (pagesCount == 1) "" else "s"}") + book.metadata.releaseDate?.let { add(it.toString()) } + book.readProgress?.let { progress -> + if (!progress.completed) { + val pagesLeft = pagesCount - progress.page + val pct = (progress.page.toFloat() / pagesCount * 100).roundToInt() + add("$pct%, $pagesLeft page${if (pagesLeft == 1) "" else "s"} left") + } + add(progress.readDate + .toLocalDateTime(TimeZone.currentSystemDefault()) + .format(localDateTimeFormat)) + } + } + } + if (segments.isEmpty()) return + Text( + text = segments.joinToString(" | "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt index 5a1eefbc..06e280bf 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt @@ -4,16 +4,20 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.ui.platform.LocalDensity import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -214,10 +218,14 @@ fun ImmersiveSeriesContent( val thumbnailTopGap = 20.dp val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp + val navBarBottom = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } LazyVerticalGrid( state = scrollState, columns = GridCells.Adaptive(gridMinWidth), horizontalArrangement = Arrangement.spacedBy(15.dp), + contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = navBarBottom + 80.dp), modifier = Modifier.fillMaxWidth(), ) { // Title + writers in a single item whose minimum height equals the thumbnail From bd083418b75f9dc281d4bc506dd4fdb7e0701327 Mon Sep 17 00:00:00 2001 From: Eyal Date: Thu, 26 Feb 2026 20:19:26 +0200 Subject: [PATCH 05/35] feat(ui): implement shared transitions and gesture system fixes - Add SharedTransitionLayout and AnimatedContent for smooth library UI transitions. - Enhance ImmersiveDetailScaffold and thumbnails with shared bounds and visibility animations. - Implement 'Gesture System Fix': - Fix pan-zoom lock and velocity jumps by tracking pointer count stability in ScalableContainer. - Add smooth double-tap to zoom in ReaderControlsOverlay with base zoom tracking in ScreenScaleState. - Synchronize velocity resets on pointer count changes to prevent kinetic scrolling jumps. - Update Paged, Continuous, and Panels reader states to support base zoom. - Add implementation and research documents: - GESTURE_SYSTEM_FIX.md: Documentation for the implemented gesture fixes. - PAGED_SWIPE_PLAN.md: Proposed plan for paged mode sticky swipe. - PANEL_VIEWER_RESEARCH.md: Research for upcoming panel viewer improvements. - REORG_TASKS.md: Task list for repository reorg. Co-Authored-By: Gemini CLI --- GESTURE_SYSTEM_FIX.md | 74 +++++++++++++ PAGED_SWIPE_PLAN.md | 100 ++++++++++++++++++ PANEL_VIEWER_RESEARCH.md | 38 +++++++ REORG_TASKS.md | 23 ++++ .../snd/komelia/ui/CompositionLocals.kt | 7 ++ .../kotlin/snd/komelia/ui/MainScreen.kt | 21 +++- .../kotlin/snd/komelia/ui/MainView.kt | 70 ++++++------ .../ui/book/immersive/ImmersiveBookContent.kt | 39 +++++++ .../komelia/ui/common/cards/BookItemCard.kt | 2 +- .../snd/komelia/ui/common/cards/ItemCard.kt | 3 +- .../komelia/ui/common/cards/SeriesItemCard.kt | 2 +- .../komelia/ui/common/images/BookThumbnail.kt | 29 ++++- .../ui/common/images/SeriesThumbnail.kt | 30 +++++- .../ui/common/images/ThumbnailImage.kt | 11 +- .../immersive/ImmersiveDetailScaffold.kt | 86 ++++++++++++--- .../immersive/ImmersiveOneshotContent.kt | 39 +++++++ .../ui/reader/image/ScreenScaleState.kt | 36 ++++++- .../ui/reader/image/common/ReaderContent.kt | 22 ++-- .../reader/image/common/ScalableContainer.kt | 18 +++- .../continuous/ContinuousReaderContent.kt | 1 + .../image/continuous/ContinuousReaderState.kt | 4 +- .../reader/image/paged/PagedReaderContent.kt | 1 + .../ui/reader/image/paged/PagedReaderState.kt | 18 ++-- .../image/panels/PanelsReaderContent.kt | 1 + .../reader/image/panels/PanelsReaderState.kt | 12 +-- 25 files changed, 596 insertions(+), 91 deletions(-) create mode 100644 GESTURE_SYSTEM_FIX.md create mode 100644 PAGED_SWIPE_PLAN.md create mode 100644 PANEL_VIEWER_RESEARCH.md create mode 100644 REORG_TASKS.md diff --git a/GESTURE_SYSTEM_FIX.md b/GESTURE_SYSTEM_FIX.md new file mode 100644 index 00000000..48a7caf1 --- /dev/null +++ b/GESTURE_SYSTEM_FIX.md @@ -0,0 +1,74 @@ +# Gesture System: Simultaneous Pan/Zoom & Velocity Fix + +This document records the implementation of the verified solution for the comic reader's gesture handling system. + +## Problems Addressed + +1. **Pan-Zoom Lock**: The gesture detector prevented panning whenever a zoom change was detected, making the UI feel stiff and unresponsive during multi-touch gestures. +2. **Velocity Jumps (The "Leap" Bug)**: When lifting one finger during a two-finger gesture, the "centroid" (the center point between fingers) would suddenly jump from the center of two fingers to the position of the remaining finger. This one-frame jump was interpreted as extreme velocity, causing the image to fly violently off-screen. +3. **Continuous Mode Regression**: Previous attempts to fix these issues often broke the native kinetic scrolling of the `LazyColumn` in continuous mode by either consuming events prematurely or introducing asynchronous timing mismatches. + +## The Solution: "Surgical Frame Filtering" + +The implementation uses a non-invasive approach that filters input data before it reaches the movement logic, ensuring the output behavior remains compatible with native scrolling. + +### 1. Pointer Count Stability Tracking +We added tracking for the number of active fingers (`lastIterationPointerCount`) inside the `detectTransformGestures` loop in `ScalableContainer.kt`. + +- **Mechanism**: We only apply zoom and pan changes if the pointer count is **stable** (exactly the same as the previous frame). +- **Result**: This automatically ignores the single "jump frame" that occurs at the exact millisecond a finger is added or removed. + +### 2. Synchronized Velocity Reset +We added a `resetVelocity()` method to `ScreenScaleState.kt` to clear the `VelocityTracker`'s history. + +- **Mechanism**: This is called whenever the finger count changes. +- **Result**: It ensures that the velocity for the "new" gesture (e.g., transitioning from two-finger zoom to one-finger pan) is calculated from a clean slate, preventing the jump from being factored into the momentum. + +### 3. Native Scroll Preservation +Crucially, the implementation continues to **not consume** the pointer events. + +- **Result**: Because the events are not consumed, the `LazyColumn` in continuous mode can still see the vertical movement and handle it using its own internal, highly-optimized physics. This preserves the smooth, kinetic feel of the vertical scroll while allowing simultaneous pan/zoom. + +## Implementation Details + +### Files Modified: +- **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt`** + - Added `resetVelocity()` helper. +- **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt`** + - Implemented `lastIterationPointerCount` tracking. + - Removed the `if/else` block that prevented simultaneous pan/zoom. + - Integrated `resetVelocity()` calls on pointer count changes. + +--- + +## Smooth Mode-Aware Double Tap to Zoom (Implemented) + +### Problem: The "Fit Height" Jump +Previously, double-tapping to zoom out would always return the image to a zoom level of 1.0 (Fit Height). If a user was reading in "Fit Width" or a padded webtoon mode, this behavior was jarring as it forced them out of their preferred layout. + +### The Solution: "Layout Base Zoom" +We implemented a system where the reader remembers the "base" zoom level intended by the current reading mode and uses it as the target for zooming out. + +#### 1. Base Zoom Tracking +In `ScreenScaleState.kt`, we added a `baseZoom` property. +- **Mechanism**: The layout engines (Paged, Continuous, Panels) now flag their initial zoom calculations as "Base Zoom" using `setZoom(..., updateBase = true)`. +- **Result**: `ScreenScaleState` always knows what the "correct" zoom level is for the current mode (e.g., 1.2x for Fit Width). + +#### 2. Animated Mode Toggle +We implemented a `toggleZoom(focus)` function that provides a smooth, kinetic transition. +- **Animation**: Uses a `SpringSpec` (`StiffnessLow`) for a natural, decelerating feel. +- **Logic**: + - If current zoom > base: Zoom out to `baseZoom`. + - If current zoom <= base: Zoom in to `max(base * 2.5, 2.5)`. +- **Focus Preservation**: The tapped point (`focus`) remains stationary under the finger as the image expands or contracts around it. + +#### 3. Reader Mode Integration +The solution is integrated across all reader modes to ensure consistent behavior: +- **Paged Mode**: Returns to "Fit Width", "Fit Height", or "Original" as defined by the user settings. +- **Continuous Mode**: Returns to the padded column width (Webtoon style). +- **Panels Mode**: Returns to the specific fit level of the current panel. + +### Files Modified: +- **`ScreenScaleState.kt`**: Implemented `baseZoom` tracking and the smooth `toggleZoom` animation. +- **`ReaderContent.kt`**: Integrated `onDoubleTap` into the `ReaderControlsOverlay` gesture detector. +- **`PagedReaderState.kt`, `ContinuousReaderState.kt`, `PanelsReaderState.kt`**: Updated layout logic to set the `baseZoom`. diff --git a/PAGED_SWIPE_PLAN.md b/PAGED_SWIPE_PLAN.md new file mode 100644 index 00000000..bd83ebd3 --- /dev/null +++ b/PAGED_SWIPE_PLAN.md @@ -0,0 +1,100 @@ +# Implementation Plan: Paged Mode Sticky Swipe Navigation + +## Goal +Implement a high-quality "Sticky Swipe" navigation for the Paged Reader. +- **Requirement 1 (The Barrier)**: If the image is zoomed in, swiping should pan the image. When hitting the edge, the movement must stop (no immediate page turn). A second, separate swipe starting from the edge is required to turn the page. +- **Requirement 2 (The Control)**: Page turns must be manual and controllable. The user should see the next page sliding in under their finger. +- **Requirement 3 (Kinetic Completion)**: Releasing a swipe should smoothly and kinetically complete the transition to the next page or snap back to the current one. +- **Requirement 4 (Safety)**: These changes must not affect Continuous (Webtoon) mode or Panels mode. + +--- + +## 1. TransformGestureDetector.kt +**Change**: Convert the gesture detector to be fully synchronous and lifecycle-aware. +- Make the `onGesture` callback a `suspend` function. +- Add an `onEnd` `suspend` callback. +- **Reason**: This allows the gesture loop to wait for the UI (Pager/LazyColumn) to finish its `scrollBy` before processing the next millisecond of touch data. This is the foundation of "frame-perfect" kinetic movement. + +```kotlin +suspend fun PointerInputScope.detectTransformGestures( + panZoomLock: Boolean = false, + onGesture: suspend (changes: List, centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit, + onEnd: suspend () -> Unit = {} +) { + // Wrap existing loop in awaitEachGesture + // Call onGesture with 'suspend' + // Invoke onEnd() when the touch loop finishes (all fingers up) +} +``` + +--- + +## 2. ScreenScaleState.kt +**Change**: Add the "Sticky" logic and synchronize the scrolling handoff. + +- **New Properties**: + - `edgeHandoffEnabled: Boolean` (Default: `false`). Only set to `true` by Paged Mode. + - `gestureStartedAtHorizontalEdge: Boolean`: Internal flag to track if a swipe is allowed to turn the page. + - `cumulativePagerScroll: Float`: Tracks total pager movement during one gesture to prevent skipping multiple pages. + - `isGestureInProgress: MutableStateFlow`: Used for UI snapping. + - `isFlinging: MutableStateFlow`: Used for UI snapping. + +- **Methods**: + - `onGestureStart()`: Sets `gestureStartedAtHorizontalEdge = isAtHorizontalEdge()`. + - `isAtHorizontalEdge()`: Returns true if image is zoomed out OR currently clamped to a left/right boundary. + - `addPan(...)`: Convert to `suspend`. Only call `applyScroll` if `!edgeHandoffEnabled || gestureStartedAtHorizontalEdge`. + - `applyScroll(...)`: Convert to `suspend`. Remove `scrollScope.launch`. Implement the "Single-Page Constraint" by clamping `cumulativePagerScroll` to `+/- ScreenWidth`. + +--- + +## 3. ScalableContainer.kt +**Change**: Integrate the new gesture lifecycle. + +```kotlin +.pointerInput(areaSize) { + detectTransformGestures( + onGesture = { changes, centroid, pan, zoom, _ -> + // On first iteration of a new touch: + if (!scaleState.isGestureInProgress.value) { + scaleState.onGestureStart() + scaleState.isGestureInProgress.value = true + } + + // ... existing pointer count / velocity reset logic ... + + if (pointerCountStable) { + scaleState.addPan(changes, pan) // Now a suspend call + } + }, + onEnd = { + scaleState.isGestureInProgress.value = false + } + ) +} +``` + +--- + +## 4. PagedReaderState.kt +**Change**: Configure the state and provide data for the Pager. + +- **Initialization**: Set `screenScaleState.edgeHandoffEnabled = true` and `screenScaleState.enableOverscrollArea(true)`. +- **New Helper**: `getImage(PageMetadata): ReaderImageResult`. + - **Reason**: The Pager needs to load the "Next" spread while the user is still swiping. This method provides direct access to the image cache/loader. + +--- + +## 5. PagedReaderContent.kt +**Change**: Replace static layout with a controlled `HorizontalPager`. + +- **Pager Setup**: + - `val pagerState = rememberPagerState(...)` + - `userScrollEnabled = false`: Crucial. The Pager must not handle its own touches; it only moves when `ScreenScaleState` calls `scrollBy`. +- **Sync Logic**: + - `LaunchedEffect(pagerState)`: Call `scaleState.setScrollState(pagerState)`. + - `LaunchedEffect(pagerState.currentPage)`: Update `pagedReaderState.onPageChange`. +- **Snapping Effect**: + - Add a `LaunchedEffect(isGestureInProgress, isFlinging)`. + - If both are false and the pager is "between" pages, call `pagerState.animateScrollToPage(target)`. +- **Rendering**: + - The pager items will render either a `TransitionPage` (Start/End) or a `DoublePageLayout`/`SinglePageLayout` using the new `getImage` helper. diff --git a/PANEL_VIEWER_RESEARCH.md b/PANEL_VIEWER_RESEARCH.md new file mode 100644 index 00000000..7f74b6fd --- /dev/null +++ b/PANEL_VIEWER_RESEARCH.md @@ -0,0 +1,38 @@ +# Comic Viewer: Panel-by-Panel Navigation Options + +This document outlines two strategies for implementing a "Full Page -> Panels -> Full Page" navigation flow in the comic book reader. + +## Option 1: State-Based Logic (Explicit Flags) +This approach involves adding explicit state flags to track whether the user is currently viewing the "intro" or "outro" full-page view for any given page. + +### Key Changes +- **`PageIndex` Data Class**: Add `isInitialFullPageActive` and `isLastPanelZoomOutActive` (already exists but needs more consistent use). +- **`nextPanel()` / `previousPanel()`**: Add conditional logic to check these flags. + - `nextPanel()`: If `isInitialFullPageActive`, move to panel index 0. If at the last panel, move to full-page zoom and set `isLastPanelZoomOutActive`. +- **`doPageLoad()`**: Initialize the state based on whether the user is moving forward (start at full page) or backward (start at "outro" full page). + +### Pros & Cons +- **Pros**: Very precise control; easy to add granular settings (e.g., "Only show full page at start"). +- **Cons**: More complex logic branches in the navigation methods; requires passing "navigation direction" through several method calls. + +--- + +## Option 2: List-Based Injection (Surgical approach) +This approach involves injecting a "full page" coordinate rectangle into the beginning and end of the panel list returned by the AI. + +### Key Changes +- **`doPageLoad()`**: After the AI detects panels and they are sorted, manually insert a rectangle covering `(0, 0, imageWidth, imageHeight)` at index `0` and at the end of the list. +- **`nextPanel()` / `previousPanel()`**: Simplify these methods to just move through the list. Remove the existing "smart" logic that skips the zoom-out based on page coverage. +- **Backward Navigation**: Update `previousPage()` and `onPageChange()` to accept a starting index, so when navigating back from Page 5, Page 4 starts at its last panel (the injected full-page view). + +### Pros & Cons +- **Pros**: Simplest implementation; leverages the existing "move to next panel" infrastructure with zero changes to the UI layer. +- **Cons**: Potential for "double views" on splash pages where the AI already detected a full-page panel (requires a simple `if` check before injection). + +--- + +## Technical Locations +- **File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt` +- **Detection Logic**: `launchDownload` +- **Sorting & List Prep**: `doPageLoad` +- **Navigation Logic**: `nextPanel` and `previousPanel` diff --git a/REORG_TASKS.md b/REORG_TASKS.md new file mode 100644 index 00000000..ff6fe6ce --- /dev/null +++ b/REORG_TASKS.md @@ -0,0 +1,23 @@ +# Task Reorganization Strategy + +To align task management with the **Agent OS** specification structure, the project's tasks (managed by the `beans` CLI) have been reorganized from a flat `.beans/` directory into spec-specific subfolders. + +## Reorganization Steps + +1. **New Task Location**: For every specification folder under `agent-os/specs/`, a dedicated `tasks/` subfolder was created to hold the related "beans" (task files). + * Example: `agent-os/specs/2026-02-23-1450-immersive-detail-screens/tasks/` + +2. **Configuration Update**: The root `.beans.yml` configuration was updated to point to the new parent directory: + ```yaml + beans: + path: agent-os/specs + ``` + +3. **Recursive Discovery**: The `beans` CLI is recursive by default. By setting the path to `agent-os/specs`, it automatically scans all subdirectories (like `spec-name/tasks/`) to find and list all tasks across all features. + +4. **Cleanup**: The original `.beans/` directory was removed once all tasks were successfully moved and verified. + +## Benefits +- **Contextual Alignment**: Tasks are now physically located alongside the specifications, plans, and standards they fulfill. +- **Multi-Spec Support**: The `beans list` command still provides a unified view of all project tasks, even though they are distributed across different spec folders. +- **Cleaner Root**: Removes the `.beans/` folder from the root of the project. diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt index a8c86822..3d50e80c 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt @@ -1,5 +1,8 @@ package snd.komelia.ui +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color @@ -41,3 +44,7 @@ val LocalNavBarColor = compositionLocalOf { null } val LocalAccentColor = compositionLocalOf { null } val LocalUseNewLibraryUI = compositionLocalOf { true } val LocalRawStatusBarHeight = staticCompositionLocalOf { 0.dp } + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = compositionLocalOf { null } +val LocalAnimatedVisibilityScope = compositionLocalOf { null } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index 95b3b605..c839e122 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -36,6 +36,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -164,6 +170,7 @@ class MainScreen( } } + @OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun MobileLayout( navigator: Navigator, @@ -179,7 +186,19 @@ class MainScreen( ModalNavigationDrawer( drawerState = vm.navBarState, drawerContent = { LibrariesNavBar(vm, navigator) }, - content = { CurrentScreen() } + content = { + AnimatedContent( + targetState = navigator.lastItem, + transitionSpec = { fadeIn(tween(400)) togetherWith fadeOut(tween(250)) }, + label = "nav", + ) { screen -> + CompositionLocalProvider(LocalAnimatedVisibilityScope provides this) { + navigator.saveableState("screen", screen) { + screen.Content() + } + } + } + } ) val isImmersiveScreen = navigator.lastItem is SeriesScreen || navigator.lastItem is BookScreen || diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt index 7adb1555..77c5e057 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt @@ -1,5 +1,7 @@ package snd.komelia.ui +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -149,6 +151,7 @@ fun MainView( } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun MainContent( platformType: PlatformType, @@ -161,45 +164,50 @@ private fun MainContent( } } - Navigator( - screen = loginScreen, - disposeBehavior = NavigatorDisposeBehavior(disposeNestedNavigators = false), - onBackPressed = null - ) { navigator -> - var canProceed by remember { mutableStateOf(komgaSharedState.authenticationState.value == Loaded) } - // FIXME this looks like a hack. Find a multiplatform way to handle this outside of composition? - // variable to track if Android app was killed in background and later restored - var wasInitializedBefore by rememberSaveable { mutableStateOf(false) } - navigator.clearEvent() + SharedTransitionLayout { + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + Navigator( + screen = loginScreen, + disposeBehavior = NavigatorDisposeBehavior(disposeNestedNavigators = false), + onBackPressed = null + ) { navigator -> + var canProceed by remember { mutableStateOf(komgaSharedState.authenticationState.value == Loaded) } + // FIXME this looks like a hack. Find a multiplatform way to handle this outside of composition? + // variable to track if Android app was killed in background and later restored + var wasInitializedBefore by rememberSaveable { mutableStateOf(false) } + navigator.clearEvent() - LaunchedEffect(Unit) { - if (canProceed) return@LaunchedEffect + LaunchedEffect(Unit) { + if (canProceed) return@LaunchedEffect - // not really necessary since Voyager navigator doesn't dispose existing MainScreen when it's replaced with LoginScreen - // when LoginScreen replaces itself back to MainScreen, it's restored to old state - // not sure if it's intended, do proper initialization here to avoid loading LoginScreen - if (wasInitializedBefore) { - komgaSharedState.tryReloadState() - } + // not really necessary since Voyager navigator doesn't dispose existing MainScreen when it's replaced with LoginScreen + // when LoginScreen replaces itself back to MainScreen, it's restored to old state + // not sure if it's intended, do proper initialization here to avoid loading LoginScreen + if (wasInitializedBefore) { + komgaSharedState.tryReloadState() + } - val currentState = komgaSharedState.authenticationState.value - when (currentState) { - AuthenticationRequired -> navigator.replaceAll(loginScreen) - Loaded -> {} - } - canProceed = true + val currentState = komgaSharedState.authenticationState.value + when (currentState) { + AuthenticationRequired -> navigator.replaceAll(loginScreen) + Loaded -> {} + } + canProceed = true + + komgaSharedState.authenticationState.collect { + wasInitializedBefore = when (it) { + AuthenticationRequired -> false + Loaded -> true + } + } + } - komgaSharedState.authenticationState.collect { - wasInitializedBefore = when (it) { - AuthenticationRequired -> false - Loaded -> true + if (canProceed) { + CurrentScreen() } } } - - if (canProceed) CurrentScreen() } - } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt index 3b3e1a03..a09cfbcb 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -1,5 +1,11 @@ package snd.komelia.ui.book.immersive +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -48,6 +54,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.lerp +import snd.komelia.ui.LocalAnimatedVisibilityScope +import snd.komelia.ui.LocalSharedTransitionScope import kotlinx.datetime.TimeZone import kotlinx.datetime.format import kotlinx.datetime.toLocalDateTime @@ -67,6 +75,9 @@ import snd.komga.client.readlist.KomgaReadList import snd.komga.client.series.KomgaSeriesId import kotlin.math.roundToInt +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ImmersiveBookContent( book: KomeliaBook, @@ -111,6 +122,32 @@ fun ImmersiveBookContent( var showDownloadConfirmationDialog by remember { mutableStateOf(false) } var sharedExpanded by remember { mutableStateOf(false) } + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + val fabOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 1f) + .animateEnterExit( + enter = fadeIn(tween(300, delayMillis = 50)), + exit = slideOutVertically(tween(200, easing = emphasizedAccelerateEasing)) { it / 2 } + + fadeOut(tween(150)) + ) + } + } + } else Modifier + + val uiOverlayModifier = if (animatedVisibilityScope != null) { + with(animatedVisibilityScope) { + Modifier.animateEnterExit( + enter = fadeIn(tween(durationMillis = 150, delayMillis = 450)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } + } else Modifier + Box(modifier = Modifier.fillMaxSize()) { // Outer HorizontalPager — slides the entire scaffold (cover + card) laterally @@ -287,6 +324,7 @@ fun ImmersiveBookContent( Row( modifier = Modifier .fillMaxWidth() + .then(uiOverlayModifier) .statusBarsPadding() .padding(start = 12.dp, end = 4.dp, top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -329,6 +367,7 @@ fun ImmersiveBookContent( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() + .then(fabOverlayModifier) .windowInsetsPadding(WindowInsets.navigationBars) .padding(bottom = 16.dp) ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt index 353f77d5..3016a6da 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt @@ -181,7 +181,7 @@ private fun BookImageOverlay( if (showTitle) { CardOutlinedText( text = book.metadata.title, - maxLines = 3 + maxLines = DEFAULT_CARD_MAX_LINES ) } if (book.deleted || libraryIsDeleted) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt index dfb2127b..f54be470 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt @@ -41,6 +41,7 @@ import snd.komelia.ui.platform.PlatformType import snd.komelia.ui.platform.cursorForHand const val defaultCardWidth = 240 +const val DEFAULT_CARD_MAX_LINES = 2 @OptIn(ExperimentalFoundationApi::class) @Composable @@ -104,7 +105,7 @@ fun overlayBorderModifier() = fun CardOutlinedText( text: String, textColor: Color = Color.Unspecified, - maxLines: Int = Int.MAX_VALUE, + maxLines: Int = DEFAULT_CARD_MAX_LINES, style: TextStyle = MaterialTheme.typography.bodyMedium.copy(color = Color.White), outlineDrawStyle: Stroke = Stroke(4f), ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt index 59d8f6df..cb5e778f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt @@ -209,7 +209,7 @@ private fun SeriesImageOverlay( ) { if (showTitle) { - CardOutlinedText(text = series.metadata.title, maxLines = 4) + CardOutlinedText(text = series.metadata.title, maxLines = DEFAULT_CARD_MAX_LINES) if (series.deleted || libraryIsDeleted) { CardOutlinedText(text = "Unavailable", textColor = MaterialTheme.colorScheme.error) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/BookThumbnail.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/BookThumbnail.kt index 4b6c2697..d6d62cfb 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/BookThumbnail.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/BookThumbnail.kt @@ -1,5 +1,10 @@ package snd.komelia.ui.common.images +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -10,10 +15,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import kotlinx.coroutines.flow.filterIsInstance import snd.komelia.image.coil.BookDefaultThumbnailRequest +import snd.komelia.ui.LocalAnimatedVisibilityScope import snd.komelia.ui.LocalKomgaEvents +import snd.komelia.ui.LocalSharedTransitionScope import snd.komga.client.book.KomgaBookId import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent +private val emphasizedEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun BookThumbnail( bookId: KomgaBookId, @@ -31,11 +42,27 @@ fun BookThumbnail( } } + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + val inSharedTransition = sharedTransitionScope != null && animatedVisibilityScope != null + val sharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedBounds( + rememberSharedContentState(key = "cover-${bookId.value}"), + animatedVisibilityScope = animatedVisibilityScope, + enter = EnterTransition.None, + exit = ExitTransition.None, + boundsTransform = { _, _ -> tween(durationMillis = 600, easing = emphasizedEasing) }, + ) + } + } else Modifier + ThumbnailImage( data = requestData, cacheKey = bookId.value, contentScale = contentScale, - modifier = modifier + crossfade = !inSharedTransition, + modifier = modifier.then(sharedModifier), ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/SeriesThumbnail.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/SeriesThumbnail.kt index eae97ed0..20937e3f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/SeriesThumbnail.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/SeriesThumbnail.kt @@ -1,5 +1,10 @@ package snd.komelia.ui.common.images +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -9,11 +14,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import snd.komelia.ui.LocalAnimatedVisibilityScope import snd.komelia.ui.LocalKomgaEvents +import snd.komelia.ui.LocalSharedTransitionScope import snd.komga.client.series.KomgaSeriesId import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent import snd.komga.client.sse.KomgaEvent.ThumbnailSeriesEvent +private val emphasizedEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SeriesThumbnail( seriesId: KomgaSeriesId, @@ -35,11 +46,28 @@ fun SeriesThumbnail( } } } + + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + val inSharedTransition = sharedTransitionScope != null && animatedVisibilityScope != null + val sharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedBounds( + rememberSharedContentState(key = "cover-${seriesId.value}"), + animatedVisibilityScope = animatedVisibilityScope, + enter = EnterTransition.None, + exit = ExitTransition.None, + boundsTransform = { _, _ -> tween(durationMillis = 600, easing = emphasizedEasing) }, + ) + } + } else Modifier + ThumbnailImage( data = requestData, cacheKey = seriesId.value, contentScale = contentScale, - modifier = modifier + crossfade = !inSharedTransition, + modifier = modifier.then(sharedModifier), ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt index 1d56fd16..15b0cf94 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt @@ -28,16 +28,9 @@ fun ThumbnailImage( ImageRequest.Builder(context) .data(data) .memoryCacheKey(cacheKey) - .memoryCacheKeyExtra( - "scale", - when (contentScale) { - ContentScale.Fit -> "Fit" - ContentScale.Crop -> "Crop" - else -> "" - } - ) + .placeholderMemoryCacheKey(cacheKey) .diskCacheKey(cacheKey) - .precision(Precision.EXACT) + .precision(Precision.INEXACT) .crossfade(crossfade) .build() } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index 326ee583..b3ce317f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -1,5 +1,8 @@ package snd.komelia.ui.common.immersive +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationVector import androidx.compose.animation.core.CubicBezierEasing @@ -7,6 +10,9 @@ import androidx.compose.animation.core.TwoWayConverter import androidx.compose.animation.core.VectorizedAnimationSpec import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.AnchoredDraggableState @@ -55,7 +61,9 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp +import snd.komelia.ui.LocalAnimatedVisibilityScope import snd.komelia.ui.LocalRawStatusBarHeight +import snd.komelia.ui.LocalSharedTransitionScope import snd.komelia.ui.common.images.ThumbnailImage import kotlin.math.roundToInt @@ -87,7 +95,10 @@ private class DirectionalSnapSpec : AnimationSpec { } } -@OptIn(ExperimentalFoundationApi::class) +private val emphasizedEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) @Composable fun ImmersiveDetailScaffold( coverData: Any, @@ -104,7 +115,49 @@ fun ImmersiveDetailScaffold( val density = LocalDensity.current val backgroundColor = cardColor ?: MaterialTheme.colorScheme.surfaceVariant - BoxWithConstraints(modifier = modifier.fillMaxSize()) { + // Read shared transition scopes OUTSIDE BoxWithConstraints (which uses SubcomposeLayout). + // SubcomposeLayout defers content composition to the layout phase, so any CompositionLocal + // reads inside it happen too late for SharedTransitionLayout's composition-phase matching. + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + val scaffoldSharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedBounds( + rememberSharedContentState(key = "cover-$coverKey"), + animatedVisibilityScope = animatedVisibilityScope, + enter = EnterTransition.None, + exit = fadeOut(tween(200)), + boundsTransform = { _, _ -> tween(durationMillis = 600, easing = emphasizedEasing) }, + ) + } + } else Modifier + + val uiEnterExitModifier = if (animatedVisibilityScope != null) { + with(animatedVisibilityScope) { + Modifier.animateEnterExit( + enter = fadeIn(tween(durationMillis = 150, delayMillis = 450)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } + } else Modifier + + val fabOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 1f) + .animateEnterExit( + enter = fadeIn(tween(300, delayMillis = 50)), + exit = slideOutVertically(tween(200, easing = emphasizedAccelerateEasing)) { it / 2 } + + fadeOut(tween(150)) + ) + } + } + } else Modifier + + Box(modifier = modifier.fillMaxSize()) { + + BoxWithConstraints(modifier = Modifier.fillMaxSize().then(scaffoldSharedModifier)) { val screenHeight = maxHeight val collapsedOffset = screenHeight * 0.65f val collapsedOffsetPx = with(density) { collapsedOffset.toPx() } @@ -217,7 +270,7 @@ fun ImmersiveDetailScaffold( ThumbnailImage( data = coverData, cacheKey = coverKey, - crossfade = false, + crossfade = true, contentScale = ContentScale.Crop, modifier = if (immersive) Modifier @@ -295,21 +348,24 @@ fun ImmersiveDetailScaffold( } } - // Layer 4: FAB — fixed at bottom, always visible, above system nav bar - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(bottom = 16.dp) - ) { - fabContent() - } - // Layer 5: Top bar - Box(modifier = Modifier.fillMaxWidth().statusBarsPadding()) { + Box(modifier = Modifier.fillMaxWidth().then(uiEnterExitModifier).statusBarsPadding()) { topBarContent() } } } + + // Layer 4: FAB — outside shared bounds so it is never clipped by the morphing container + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .then(fabOverlayModifier) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) + ) { + fabContent() + } + + } // outer Box } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt index 599aaab5..28bac5a3 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -1,5 +1,11 @@ package snd.komelia.ui.oneshot.immersive +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -42,6 +48,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.lerp +import snd.komelia.ui.LocalAnimatedVisibilityScope +import snd.komelia.ui.LocalSharedTransitionScope import kotlinx.datetime.TimeZone import kotlinx.datetime.format import kotlinx.datetime.toLocalDateTime @@ -65,6 +73,9 @@ import snd.komga.client.library.KomgaLibrary import snd.komga.client.readlist.KomgaReadList import snd.komga.client.series.KomgaSeries +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ImmersiveOneshotContent( series: KomgaSeries, @@ -87,6 +98,32 @@ fun ImmersiveOneshotContent( ) { var showDownloadConfirmationDialog by remember { mutableStateOf(false) } + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + val fabOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 1f) + .animateEnterExit( + enter = fadeIn(tween(300, delayMillis = 50)), + exit = slideOutVertically(tween(200, easing = emphasizedAccelerateEasing)) { it / 2 } + + fadeOut(tween(150)) + ) + } + } + } else Modifier + + val uiOverlayModifier = if (animatedVisibilityScope != null) { + with(animatedVisibilityScope) { + Modifier.animateEnterExit( + enter = fadeIn(tween(durationMillis = 150, delayMillis = 450)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } + } else Modifier + Box(modifier = Modifier.fillMaxSize()) { ImmersiveDetailScaffold( @@ -253,6 +290,7 @@ fun ImmersiveOneshotContent( Row( modifier = Modifier .fillMaxWidth() + .then(uiOverlayModifier) .statusBarsPadding() .padding(start = 12.dp, end = 4.dp, top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -294,6 +332,7 @@ fun ImmersiveOneshotContent( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() + .then(fabOverlayModifier) .windowInsetsPadding(WindowInsets.navigationBars) .padding(bottom = 16.dp) ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt index 26ac79f6..980f5679 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt @@ -2,9 +2,11 @@ package snd.komelia.ui.reader.image import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation.Horizontal @@ -58,6 +60,12 @@ class ScreenScaleState { @Volatile private var scrollJob: Job? = null + @Volatile + private var zoomJob: Job? = null + + @Volatile + private var baseZoom = 1f + @Volatile private var enableOverscrollArea = false @@ -237,8 +245,9 @@ class ScreenScaleState { this.scrollReversed.value = reversed } - fun setZoom(zoom: Float, focus: Offset = Offset.Zero) { + fun setZoom(zoom: Float, focus: Offset = Offset.Zero, updateBase: Boolean = false) { val newZoom = zoom.coerceIn(zoomLimits.value) + if (updateBase) baseZoom = newZoom val newOffset = Transformation.offsetOf( point = transformation.value.pointOf(focus), transformedPoint = focus, @@ -254,6 +263,30 @@ class ScreenScaleState { applyLimits() } + fun toggleZoom(focus: Offset) { + val coroutineScope = composeScope ?: return + zoomJob?.cancel() + val currentZoom = zoom.value + val targetZoom = if (currentZoom > baseZoom + 0.1f) { + baseZoom + } else { + max(baseZoom * 2.5f, 2.5f) + } + + zoomJob = coroutineScope.launch { + AnimationState(initialValue = currentZoom).animateTo( + targetValue = targetZoom, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) { + setZoom(value, focus) + } + } + } + + fun resetVelocity() { + velocityTracker.resetTracking() + } + fun enableOverscrollArea(enable: Boolean) { this.enableOverscrollArea = enable applyLimits() @@ -262,6 +295,7 @@ class ScreenScaleState { fun apply(other: ScreenScaleState) { scrollJob?.cancel() currentOffset = other.currentOffset + this.baseZoom = other.baseZoom if (other.targetSize.value != this.targetSize.value || other.zoom.value != this.zoom.value) { this.areaSize.value = other.areaSize.value diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt index 3d1c1325..a6c2e280 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp import androidx.compose.ui.input.key.isAltPressed @@ -195,6 +196,7 @@ fun ReaderControlsOverlay( isSettingsMenuOpen: Boolean, onSettingsMenuToggle: () -> Unit, contentAreaSize: IntSize, + scaleState: ScreenScaleState, modifier: Modifier, content: @Composable () -> Unit, ) { @@ -211,6 +213,7 @@ fun ReaderControlsOverlay( else coroutineScope.launch { onPrevPageClick() } } + val areaCenter = remember(contentAreaSize) { Offset(contentAreaSize.width / 2f, contentAreaSize.height / 2f) } Box( modifier = modifier .fillMaxSize() @@ -221,14 +224,19 @@ fun ReaderControlsOverlay( onSettingsMenuToggle, isSettingsMenuOpen ) { - detectTapGestures { offset -> - val actionWidth = contentAreaSize.width.toFloat() / 3 - when (offset.x) { - in 0f.. leftAction() - in actionWidth..actionWidth * 2 -> centerAction() - else -> rightAction() + detectTapGestures( + onTap = { offset -> + val actionWidth = contentAreaSize.width.toFloat() / 3 + when (offset.x) { + in 0f.. leftAction() + in actionWidth..actionWidth * 2 -> centerAction() + else -> rightAction() + } + }, + onDoubleTap = { offset -> + scaleState.toggleZoom(offset - areaCenter) } - } + ) }, contentAlignment = Alignment.Center ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt index dbdad250..d6633137 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt @@ -100,12 +100,20 @@ fun ScalableContainer( } .pointerInput(areaSize) { - detectTransformGestures { event, centroid, pan, zoom, _ -> - if (zoom != 1.0f) { - scaleState.multiplyZoom(zoom, centroid - areaCenter) - } else { - scaleState.addPan(event, pan) + var lastIterationPointerCount = 0 + detectTransformGestures { changes, centroid, pan, zoom, _ -> + val currentPointerCount = changes.count { it.pressed } + if (currentPointerCount != lastIterationPointerCount) { + scaleState.resetVelocity() } + + if (currentPointerCount == lastIterationPointerCount) { + if (zoom != 1.0f) { + scaleState.multiplyZoom(zoom, centroid - areaCenter) + } + scaleState.addPan(changes, pan) + } + lastIterationPointerCount = currentPointerCount } } .onPointerEvent(PointerEventType.Scroll) { event -> diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt index 69bd0cbb..469a918d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt @@ -107,6 +107,7 @@ fun BoxScope.ContinuousReaderContent( onNexPageClick = { coroutineScope.launch { continuousReaderState.scrollScreenForward() } }, onPrevPageClick = { coroutineScope.launch { continuousReaderState.scrollScreenBackward() } }, contentAreaSize = areaSize, + scaleState = screenScaleState, isSettingsMenuOpen = showSettingsMenu, onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, modifier = Modifier.onKeyEvent { event -> diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt index e6de32b6..e1c7e1b8 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt @@ -217,7 +217,7 @@ class ContinuousReaderState( .filter { it != IntSize.Zero } .onEach { applyPadding() - screenScaleState.setZoom(0f) + screenScaleState.setZoom(0f, updateBase = true) } .launchIn(stateScope) @@ -699,7 +699,7 @@ class ContinuousReaderState( fun onSidePaddingChange(fraction: Float) { this.sidePaddingFraction.value = fraction applyPadding() - screenScaleState.setZoom(0f) + screenScaleState.setZoom(0f, updateBase = true) stateScope.launch { settingsRepository.putContinuousReaderPadding(fraction) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index 77a58a70..0067cb95 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -71,6 +71,7 @@ fun BoxScope.PagedReaderContent( onNexPageClick = pagedReaderState::nextPage, onPrevPageClick = pagedReaderState::previousPage, contentAreaSize = currentContainerSize, + scaleState = screenScaleState, isSettingsMenuOpen = showSettingsMenu, onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, modifier = Modifier.onKeyEvent { event -> diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index 2a74acb1..2f7789cf 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -558,7 +558,7 @@ class PagedReaderState( } when (scaleType) { - LayoutScaleType.SCREEN -> scaleState.setZoom(0f) + LayoutScaleType.SCREEN -> scaleState.setZoom(0f, updateBase = true) LayoutScaleType.FIT_WIDTH -> { if (!stretchToFit && areaSize.width > actualSpreadSize.width) { val newZoom = zoomForOriginalSize( @@ -566,9 +566,9 @@ class PagedReaderState( fitToScreenSize, scaleState.scaleFor100PercentZoom() ) - scaleState.setZoom(newZoom.coerceAtMost(1.0f)) - } else if (fitToScreenSize.width < areaSize.width) scaleState.setZoom(1f) - else scaleState.setZoom(0f) + scaleState.setZoom(newZoom.coerceAtMost(1.0f), updateBase = true) + } else if (fitToScreenSize.width < areaSize.width) scaleState.setZoom(1f, updateBase = true) + else scaleState.setZoom(0f, updateBase = true) } LayoutScaleType.FIT_HEIGHT -> { @@ -578,10 +578,10 @@ class PagedReaderState( fitToScreenSize, scaleState.scaleFor100PercentZoom() ) - scaleState.setZoom(newZoom.coerceAtMost(1.0f)) + scaleState.setZoom(newZoom.coerceAtMost(1.0f), updateBase = true) - } else if (fitToScreenSize.height < areaSize.height) scaleState.setZoom(1f) - else scaleState.setZoom(0f) + } else if (fitToScreenSize.height < areaSize.height) scaleState.setZoom(1f, updateBase = true) + else scaleState.setZoom(0f, updateBase = true) } LayoutScaleType.ORIGINAL -> { @@ -591,9 +591,9 @@ class PagedReaderState( fitToScreenSize, scaleState.scaleFor100PercentZoom() ) - scaleState.setZoom(newZoom) + scaleState.setZoom(newZoom, updateBase = true) - } else scaleState.setZoom(0f) + } else scaleState.setZoom(0f, updateBase = true) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index 8a6ba5ef..19502017 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -65,6 +65,7 @@ fun BoxScope.PanelsReaderContent( onNexPageClick = panelsReaderState::nextPanel, onPrevPageClick = panelsReaderState::previousPanel, contentAreaSize = currentContainerSize, + scaleState = screenScaleState, isSettingsMenuOpen = showSettingsMenu, onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, modifier = Modifier.onKeyEvent { event -> diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index 8e5002bd..c45f84cb 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -374,7 +374,7 @@ class PanelsReaderState( currentPageIndex.update { PageIndex(pageIndex, 0, false) } transitionPage.value = null screenScaleState.enableOverscrollArea(false) - screenScaleState.setZoom(0f) + screenScaleState.setZoom(0f, updateBase = true) } val page = downloadJob.await() @@ -486,7 +486,7 @@ class PanelsReaderState( ): ScreenScaleState { val defaultScale = ScreenScaleState() defaultScale.setAreaSize(containerSize) - defaultScale.setZoom(0f) + defaultScale.setZoom(0f, updateBase = true) val image = page.imageResult?.image ?: return defaultScale val scaleState = ScreenScaleState() @@ -497,7 +497,7 @@ class PanelsReaderState( val panels = page.panelData?.panels if (panels.isNullOrEmpty()) { - scaleState.setZoom(0f) + scaleState.setZoom(0f, updateBase = true) } else { val firstPanel = panels.first() val imageSize = image.getOriginalImageSize().getOrNull() ?: return defaultScale @@ -507,7 +507,7 @@ class PanelsReaderState( targetSize = fitToScreenSize, panel = firstPanel ) - scaleState.setZoom(zoom) + scaleState.setZoom(zoom, updateBase = true) scaleState.setOffset(offset) } @@ -518,7 +518,7 @@ class PanelsReaderState( // val areaSize = screenScaleState.areaSize.value // val startX = 0 - areaSize.width.toFloat() // val startY = 0 - areaSize.height.toFloat() - screenScaleState.setZoom(0f) + screenScaleState.setZoom(0f, updateBase = true) screenScaleState.scrollTo(Offset(0f, 0f)) } @@ -535,7 +535,7 @@ class PanelsReaderState( targetSize = targetSize, panel = panel ) - screenScaleState.setZoom(zoom) + screenScaleState.setZoom(zoom, updateBase = true) screenScaleState.scrollTo(offset) } From 940a7a025b09acec5e474d7616493bf1a2c104f0 Mon Sep 17 00:00:00 2001 From: Eyal Date: Thu, 26 Feb 2026 23:59:52 +0200 Subject: [PATCH 06/35] feat(reader): implement kinetic paged sticky swipe with RTL support - Implement RTL-aware directional sticky barrier: prevents accidental page turns when zoomed in. - Implement kinetic momentum: uses dispatchRawDelta and performFling for smooth, natural movement. - Implement robust kinetic snapping: automatically settles on the closest page when gesture or fling ends. - Replace static paged layout with controlled HorizontalPager for frame-perfect synchronization. - Fix RTL gesture reversal: ensure scroll orientation and barriers update reactively to direction changes. - Restore synchronous pan/zoom math: eliminates race conditions and restores precise gesture control. - Optimize image loading: refined LaunchedEffect and remembered spread metadata for smoother paging. Co-Authored-By: Gemini CLI --- .../ui/reader/image/ScreenScaleState.kt | 117 +++++++++++++----- .../reader/image/common/ScalableContainer.kt | 34 +++-- .../image/common/TransformGestureDetector.kt | 4 +- .../image/continuous/ContinuousReaderState.kt | 20 +-- .../reader/image/paged/PagedReaderContent.kt | 86 ++++++++++++- .../ui/reader/image/paged/PagedReaderState.kt | 19 +++ 6 files changed, 222 insertions(+), 58 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt index 980f5679..67b054b4 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation.Horizontal import androidx.compose.foundation.gestures.Orientation.Vertical import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.PointerInputChange @@ -54,6 +53,13 @@ class ScreenScaleState { val transformation = MutableStateFlow(Transformation(offset = Offset.Zero, scale = 1f)) + val isGestureInProgress = MutableStateFlow(false) + val isFlinging = MutableStateFlow(false) + var edgeHandoffEnabled = false + private var gestureStartedAtLeftEdge = false + private var gestureStartedAtRightEdge = false + private var cumulativePagerScroll = 0f + @Volatile var composeScope: CoroutineScope? = null @@ -155,36 +161,57 @@ class ScreenScaleState { val velocity = velocityTracker.calculateVelocity().div(scale) velocityTracker.resetTracking() + isFlinging.value = true var lastValue = Offset(0f, 0f) - AnimationState( - typeConverter = Offset.VectorConverter, - initialValue = Offset.Zero, - initialVelocity = Offset(velocity.x, velocity.y), - ).animateDecay(spec) { - val delta = value - lastValue - lastValue = value - - if (scrollState.value == null) { - val canPanHorizontally = when { - delta.x < 0 -> canPanLeft() - delta.x > 0 -> canPanRight() - else -> false - } - val canPanVertically = when { - delta.y > 0 -> canPanDown() - delta.y < 0 -> canPanUp() - else -> false - } - if (!canPanHorizontally && !canPanVertically) { - this.cancelAnimation() - return@animateDecay + try { + AnimationState( + typeConverter = Offset.VectorConverter, + initialValue = Offset.Zero, + initialVelocity = Offset(velocity.x, velocity.y), + ).animateDecay(spec) { + val delta = value - lastValue + lastValue = value + + if (scrollState.value == null) { + val canPanHorizontally = when { + delta.x < 0 -> canPanLeft() + delta.x > 0 -> canPanRight() + else -> false + } + val canPanVertically = when { + delta.y > 0 -> canPanDown() + delta.y < 0 -> canPanUp() + else -> false + } + if (!canPanHorizontally && !canPanVertically) { + this.cancelAnimation() + return@animateDecay + } } - } - addPan(delta) + addPan(delta) + } + } finally { + isFlinging.value = false } } + fun onGestureStart() { + gestureStartedAtLeftEdge = isAtLeftEdge() + gestureStartedAtRightEdge = isAtRightEdge() + cumulativePagerScroll = 0f + } + + private fun isAtLeftEdge(): Boolean { + if (zoom.value <= baseZoom + 0.01f) return true + return currentOffset.x >= offsetXLimits.value.endInclusive - 0.5f + } + + private fun isAtRightEdge(): Boolean { + if (zoom.value <= baseZoom + 0.01f) return true + return currentOffset.x <= offsetXLimits.value.start + 0.5f + } + private fun canPanUp(): Boolean { return currentOffset.y > offsetYLimits.value.start } @@ -208,10 +235,27 @@ class ScreenScaleState { applyLimits() val delta = (newOffset - currentOffset) - when (scrollOrientation.value) { - Vertical -> applyScroll((delta / -zoomToScale).y) - Horizontal -> applyScroll((delta / -zoomToScale).x) - null -> {} + val pagerValue = (delta.x / -zoomToScale) + val isRtl = scrollReversed.value + + val allowNextPage = if (isRtl) { + gestureStartedAtLeftEdge && pagerValue > 0 + } else { + gestureStartedAtRightEdge && pagerValue > 0 + } + + val allowPrevPage = if (isRtl) { + gestureStartedAtRightEdge && pagerValue < 0 + } else { + gestureStartedAtLeftEdge && pagerValue < 0 + } + + if (!edgeHandoffEnabled || allowNextPage || allowPrevPage) { + when (scrollOrientation.value) { + Vertical -> applyScroll((delta / -zoomToScale).y) + Horizontal -> applyScroll(pagerValue) + null -> {} + } } } @@ -224,7 +268,20 @@ class ScreenScaleState { if (value == 0f) return val scrollState = this.scrollState.value if (scrollState != null) { - scrollScope.launch { scrollState.scrollBy(if (scrollReversed.value) -value else value) } + val delta = if (scrollReversed.value) -value else value + if (edgeHandoffEnabled) { + val screenWidth = areaSize.value.width.toFloat() + val remaining = if (delta > 0) { + (screenWidth - cumulativePagerScroll).coerceAtLeast(0f) + } else { + (-screenWidth - cumulativePagerScroll).coerceAtMost(0f) + } + val consumed = if (delta > 0) min(delta, remaining) else max(delta, remaining) + cumulativePagerScroll += consumed + scrollState.dispatchRawDelta(consumed) + } else { + scrollState.dispatchRawDelta(delta) + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt index d6633137..dfeb6bfa 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt @@ -101,20 +101,30 @@ fun ScalableContainer( } .pointerInput(areaSize) { var lastIterationPointerCount = 0 - detectTransformGestures { changes, centroid, pan, zoom, _ -> - val currentPointerCount = changes.count { it.pressed } - if (currentPointerCount != lastIterationPointerCount) { - scaleState.resetVelocity() - } + detectTransformGestures( + onGesture = { changes, centroid, pan, zoom, _ -> + if (!scaleState.isGestureInProgress.value) { + scaleState.onGestureStart() + scaleState.isGestureInProgress.value = true + } + + val currentPointerCount = changes.count { it.pressed } + if (currentPointerCount != lastIterationPointerCount) { + scaleState.resetVelocity() + } - if (currentPointerCount == lastIterationPointerCount) { - if (zoom != 1.0f) { - scaleState.multiplyZoom(zoom, centroid - areaCenter) + if (currentPointerCount == lastIterationPointerCount) { + if (zoom != 1.0f) { + scaleState.multiplyZoom(zoom, centroid - areaCenter) + } + scaleState.addPan(changes, pan) } - scaleState.addPan(changes, pan) + lastIterationPointerCount = currentPointerCount + }, + onEnd = { + scaleState.isGestureInProgress.value = false } - lastIterationPointerCount = currentPointerCount - } + ) } .onPointerEvent(PointerEventType.Scroll) { event -> val scrollDelta = with(density) { with(scrollConfig) { calculateMouseWheelScroll(event, size) } } @@ -125,7 +135,7 @@ fun ScalableContainer( scaleState.addZoom(zoom, centroid - areaCenter) } else { val maxDelta = if (abs(scrollDelta.y) > abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x - val pan = (if (scaleState.scrollReversed.value) -maxDelta else maxDelta) + val pan = maxDelta when (scrollOrientation) { Vertical -> scaleState.addPan(Offset(0f, pan)) Horizontal -> scaleState.addPan(Offset(pan, 0f)) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/TransformGestureDetector.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/TransformGestureDetector.kt index e054ff74..1846b6d2 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/TransformGestureDetector.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/TransformGestureDetector.kt @@ -48,7 +48,8 @@ import kotlin.math.atan2 */ suspend fun PointerInputScope.detectTransformGestures( panZoomLock: Boolean = false, - onGesture: (changes: List, centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit + onGesture: (changes: List, centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit, + onEnd: () -> Unit = {} ) { awaitEachGesture { var rotation = 0f @@ -103,6 +104,7 @@ suspend fun PointerInputScope.detectTransformGestures( } } } while (!canceled && event.changes.fastAny { it.pressed }) + onEnd() } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt index e1c7e1b8..e3b1b609 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt @@ -357,17 +357,19 @@ class ContinuousReaderState( } fun scrollBy(amount: Float) { - when (readingDirection.value) { - TOP_TO_BOTTOM -> { - screenScaleState.addPan(Offset(0f, amount)) - } + stateScope.launch { + when (readingDirection.value) { + TOP_TO_BOTTOM -> { + screenScaleState.addPan(Offset(0f, amount)) + } - LEFT_TO_RIGHT -> { - screenScaleState.addPan(Offset(amount, 0f)) - } + LEFT_TO_RIGHT -> { + screenScaleState.addPan(Offset(amount, 0f)) + } - RIGHT_TO_LEFT -> { - screenScaleState.addPan(Offset(amount, 0f)) + RIGHT_TO_LEFT -> { + screenScaleState.addPan(Offset(amount, 0f)) + } } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index 0067cb95..4a920b67 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -1,16 +1,25 @@ package snd.komelia.ui.reader.image.paged +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key @@ -24,6 +33,7 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import snd.komelia.image.ReaderImageResult import snd.komelia.settings.model.PageDisplayLayout.DOUBLE_PAGES import snd.komelia.settings.model.PageDisplayLayout.DOUBLE_PAGES_NO_COVER import snd.komelia.settings.model.PageDisplayLayout.SINGLE_PAGE @@ -39,6 +49,7 @@ import snd.komelia.ui.reader.image.paged.PagedReaderState.Page import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart +import kotlin.math.abs @Composable fun BoxScope.PagedReaderContent( @@ -59,17 +70,52 @@ fun BoxScope.PagedReaderContent( LEFT_TO_RIGHT -> LayoutDirection.Ltr RIGHT_TO_LEFT -> LayoutDirection.Rtl } - val pages = pagedReaderState.currentSpread.collectAsState().value.pages + val spreads = pagedReaderState.pageSpreads.collectAsState().value + val currentSpreadIndex = pagedReaderState.currentSpreadIndex.collectAsState().value val layout = pagedReaderState.layout.collectAsState().value val layoutOffset = pagedReaderState.layoutOffset.collectAsState().value val currentContainerSize = screenScaleState.areaSize.collectAsState().value + val pagerState = rememberPagerState( + initialPage = currentSpreadIndex, + pageCount = { spreads.size } + ) + + LaunchedEffect(pagerState, readingDirection) { + screenScaleState.setScrollState(pagerState) + screenScaleState.setScrollOrientation(Orientation.Horizontal, readingDirection == RIGHT_TO_LEFT) + } + + LaunchedEffect(currentSpreadIndex) { + if (pagerState.currentPage != currentSpreadIndex) { + pagerState.scrollToPage(currentSpreadIndex) + } + } + + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage < spreads.size) { + pagedReaderState.onPageChange(pagerState.currentPage) + } + } + + // Snapping effect + val isGestureInProgress by screenScaleState.isGestureInProgress.collectAsState() + val isFlinging by screenScaleState.isFlinging.collectAsState() + LaunchedEffect(isGestureInProgress, isFlinging) { + if (!isGestureInProgress && !isFlinging) { + val pageOffset = pagerState.currentPageOffsetFraction + if (abs(pageOffset) > 0.001f) { + pagerState.animateScrollToPage(pagerState.currentPage) + } + } + } + val coroutineScope = rememberCoroutineScope() ReaderControlsOverlay( readingDirection = layoutDirection, - onNexPageClick = pagedReaderState::nextPage, - onPrevPageClick = pagedReaderState::previousPage, + onNexPageClick = { coroutineScope.launch { pagedReaderState.nextPage() } }, + onPrevPageClick = { coroutineScope.launch { pagedReaderState.previousPage() } }, contentAreaSize = currentContainerSize, scaleState = screenScaleState, isSettingsMenuOpen = showSettingsMenu, @@ -96,9 +142,36 @@ fun BoxScope.PagedReaderContent( if (transitionPage != null) { TransitionPage(transitionPage) } else { - when (layout) { - SINGLE_PAGE -> pages.firstOrNull()?.let { SinglePageLayout(it) } - DOUBLE_PAGES, DOUBLE_PAGES_NO_COVER -> DoublePageLayout(pages, readingDirection) + if (spreads.isNotEmpty()) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + reverseLayout = readingDirection == RIGHT_TO_LEFT, + modifier = Modifier.fillMaxSize(), + key = { if (it < spreads.size) spreads[it].first().pageNumber else it } + ) { pageIdx -> + if (pageIdx >= spreads.size) return@HorizontalPager + val spreadMetadata = spreads[pageIdx] + val spreadPages = remember(spreadMetadata) { + spreadMetadata.map { meta -> + val imageResult = mutableStateOf(null) + meta to imageResult + } + } + + spreadPages.forEach { (meta, imageResultState) -> + LaunchedEffect(meta) { + imageResultState.value = pagedReaderState.getImage(meta) + } + } + + val pages = spreadPages.map { (meta, resultState) -> Page(meta, resultState.value) } + + when (layout) { + SINGLE_PAGE -> pages.firstOrNull()?.let { SinglePageLayout(it) } + DOUBLE_PAGES, DOUBLE_PAGES_NO_COVER -> DoublePageLayout(pages, readingDirection) + } + } } } } @@ -108,6 +181,7 @@ fun BoxScope.PagedReaderContent( @Composable private fun TransitionPage(page: TransitionPage) { + // ... rest of file same Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index 2f7789cf..c4dc5172 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -103,6 +103,8 @@ class PagedReaderState( screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) + screenScaleState.enableOverscrollArea(false) + screenScaleState.edgeHandoffEnabled = true combine( screenScaleState.transformation, @@ -136,6 +138,8 @@ class PagedReaderState( fun stop() { stateScope.coroutineContext.cancelChildren() + screenScaleState.enableOverscrollArea(false) + screenScaleState.edgeHandoffEnabled = false imageCache.invalidateAll() } @@ -396,6 +400,20 @@ class PagedReaderState( launchSpreadLoadJob(pagesMeta) } + suspend fun getImage(page: PageMetadata): ReaderImageResult { + val pageId = page.toPageId() + val cached = imageCache.get(pageId) + return if (cached != null && !cached.isCancelled) { + cached.await().imageResult ?: ReaderImageResult.Error(Exception("Image result is null")) + } else { + val job = pageLoadScope.async { + val result = imageLoader.loadReaderImage(page.bookId, page.pageNumber) + Page(page, result) + }.also { imageCache.put(pageId, it) } + job.await().imageResult ?: ReaderImageResult.Error(Exception("Image result is null")) + } + } + private fun getMaxPageSize(pages: List, containerSize: IntSize): IntSize { return IntSize( width = containerSize.width / pages.size, @@ -528,6 +546,7 @@ class PagedReaderState( fun onReadingDirectionChange(readingDirection: PagedReadingDirection) { this.readingDirection.value = readingDirection + screenScaleState.setScrollOrientation(Orientation.Horizontal, readingDirection == RIGHT_TO_LEFT) stateScope.launch { settingsRepository.putPagedReaderReadingDirection(readingDirection) } } From ca5dd1e560f9cc22bd2e298c389bf4ad20190e3b Mon Sep 17 00:00:00 2001 From: Eyal Date: Fri, 27 Feb 2026 01:43:27 +0200 Subject: [PATCH 07/35] feat(reader): implement full page context sequence for panel navigation - Add 'Show full page' setting to Panel Reader with options: None, Before, After, Both. - Implement intelligent full-page injection in PanelsReaderState: - Automatically show full page before/after panel sequences based on settings. - Prevent redundant views on splash pages (1 large panel) or empty detections. - Support seamless bidirectional navigation (forward/backward) through the injected sequence. - Persistence and Data Model: - Add PanelsFullPageDisplayMode enum to domain layer. - Implement database migration (V16) to persist display mode setting. - Update repository and Exposed implementation for SQLite storage. - UI Enhancements: - Add 'Show full page' selection to mobile bottom sheet reader settings. - Add 'Show full page' dropdown to desktop side menu reader settings. Co-Authored-By: Gemini CLI --- .../settings/ImageReaderSettingsRepository.kt | 4 + .../model/PanelsFullPageDisplayMode.kt | 8 + .../snd/komelia/db/ImageReaderSettings.kt | 3 + .../ReaderSettingsRepositoryWrapper.kt | 9 + .../app/V16__panel_reader_settings.sql | 1 + .../komelia/db/migrations/AppMigrations.kt | 4 +- .../ExposedImageReaderSettingsRepository.kt | 4 + .../db/tables/ImageReaderSettingsTable.kt | 2 + .../reader/image/panels/PanelsReaderState.kt | 215 ++++++++---------- .../settings/BottomSheetSettingsOverlay.kt | 13 ++ .../reader/image/settings/SettingsSideMenu.kt | 25 +- 11 files changed, 164 insertions(+), 124 deletions(-) create mode 100644 komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt create mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt index 4ce07460..dfd0009f 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt @@ -9,6 +9,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType @@ -78,4 +79,7 @@ interface ImageReaderSettingsRepository { fun getUpscalerOnnxModel(): Flow suspend fun putUpscalerOnnxModel(name: PlatformFile?) + + fun getPanelsFullPageDisplayMode(): Flow + suspend fun putPanelsFullPageDisplayMode(mode: PanelsFullPageDisplayMode) } \ No newline at end of file diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt new file mode 100644 index 00000000..c71d7c6e --- /dev/null +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt @@ -0,0 +1,8 @@ +package snd.komelia.settings.model + +enum class PanelsFullPageDisplayMode { + NONE, + BEFORE, + AFTER, + BOTH +} diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt index 8b279773..d0b2c192 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt @@ -9,6 +9,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType import snd.komelia.settings.model.ReaderType.PAGED @@ -39,4 +40,6 @@ data class ImageReaderSettings( val ortUpscalerUserModelPath: PlatformFile? = null, val ortUpscalerDeviceId: Int = 0, val ortUpscalerTileSize: Int = 512, + + val panelsFullPageDisplayMode: PanelsFullPageDisplayMode = PanelsFullPageDisplayMode.NONE, ) \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt index 59e4c553..09f5b950 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt @@ -12,6 +12,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType @@ -194,4 +195,12 @@ class ReaderSettingsRepositoryWrapper( override suspend fun putUpscalerOnnxModel(name: PlatformFile?) { wrapper.transform { it.copy(ortUpscalerUserModelPath = name) } } + + override fun getPanelsFullPageDisplayMode(): Flow { + return wrapper.mapState { it.panelsFullPageDisplayMode } + } + + override suspend fun putPanelsFullPageDisplayMode(mode: PanelsFullPageDisplayMode) { + wrapper.transform { it.copy(panelsFullPageDisplayMode = mode) } + } } \ No newline at end of file diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql new file mode 100644 index 00000000..f9d7fa74 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql @@ -0,0 +1 @@ +ALTER TABLE ImageReaderSettings ADD COLUMN panels_full_page_display_mode TEXT NOT NULL DEFAULT 'NONE'; diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt index 590c84f3..0b41aef7 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt @@ -20,7 +20,9 @@ class AppMigrations : MigrationResourcesProvider() { "V11__home_filters.sql", "V12__offline_mode.sql", "V13__ui_colors.sql", - "V14__new_library_ui.sql", + "V14__immersive_layout.sql", + "V15__new_library_ui.sql", + "V16__panel_reader_settings.sql", ) override suspend fun getMigration(name: String): ByteArray? { diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt index 1049c787..37382a1d 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt @@ -17,6 +17,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType @@ -53,6 +54,8 @@ class ExposedImageReaderSettingsRepository(database: Database) : ExposedReposito ?.let { PlatformFile(it) }, ortUpscalerDeviceId = it[ImageReaderSettingsTable.ortDeviceId], ortUpscalerTileSize = it[ImageReaderSettingsTable.ortUpscalerTileSize], + panelsFullPageDisplayMode = it[ImageReaderSettingsTable.panelsFullPageDisplayMode] + .let { mode -> PanelsFullPageDisplayMode.valueOf(mode) }, ) } } @@ -84,6 +87,7 @@ class ExposedImageReaderSettingsRepository(database: Database) : ExposedReposito it[ortUpscalerUserModelPath] = settings.ortUpscalerUserModelPath?.path it[ortDeviceId] = settings.ortUpscalerDeviceId it[ortUpscalerTileSize] = settings.ortUpscalerTileSize + it[panelsFullPageDisplayMode] = settings.panelsFullPageDisplayMode.name } } } diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt index 45bf986d..eee419cf 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt @@ -34,5 +34,7 @@ object ImageReaderSettingsTable : Table("ImageReaderSettings") { val ortUpscalerTileSize = integer("onnx_runtime_tile_size") val ortUpscalerUserModelPath = text("onnx_runtime_model_path").nullable() + val panelsFullPageDisplayMode = text("panels_full_page_display_mode").default("NONE") + override val primaryKey = PrimaryKey(bookId) } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index c45f84cb..e84eaaed 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -42,6 +42,7 @@ import snd.komelia.settings.ImageReaderSettingsRepository import snd.komelia.settings.model.PagedReadingDirection import snd.komelia.settings.model.PagedReadingDirection.LEFT_TO_RIGHT import snd.komelia.settings.model.PagedReadingDirection.RIGHT_TO_LEFT +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.ui.reader.image.BookState import snd.komelia.ui.reader.image.PageMetadata import snd.komelia.ui.reader.image.ReaderState @@ -90,17 +91,20 @@ class PanelsReaderState( val pageMetadata: MutableStateFlow> = MutableStateFlow(emptyList()) - val currentPageIndex = MutableStateFlow(PageIndex(0, 0, false)) + val currentPageIndex = MutableStateFlow(PageIndex(0, 0)) val currentPage: MutableStateFlow = MutableStateFlow(null) val transitionPage: MutableStateFlow = MutableStateFlow(null) val readingDirection = MutableStateFlow(LEFT_TO_RIGHT) + val fullPageDisplayMode = MutableStateFlow(PanelsFullPageDisplayMode.NONE) + suspend fun initialize() { readingDirection.value = when (readerState.series.value?.metadata?.readingDirection) { KomgaReadingDirection.LEFT_TO_RIGHT -> LEFT_TO_RIGHT KomgaReadingDirection.RIGHT_TO_LEFT -> RIGHT_TO_LEFT else -> settingsRepository.getPagedReaderReadingDirection().first() } + fullPageDisplayMode.value = settingsRepository.getPanelsFullPageDisplayMode().first() screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) @@ -112,35 +116,15 @@ class PanelsReaderState( .drop(1).conflate() .onEach { currentPage.value?.let { page -> - updateImageState(page, screenScaleState) + updateImageState(page, screenScaleState, currentPageIndex.value.panel) delay(100) } } .launchIn(stateScope) - readingDirection.drop(1).onEach { readingDirection -> - val page = currentPage.value - val panelData = page?.panelData - if (panelData != null) { - val sortedPanels = sortPanels( - panels = panelData.panels, - imageSize = panelData.originalImageSize, - readingDirection = readingDirection - ) - currentPage.value = page.copy(panelData = panelData.copy(panels = sortedPanels)) - currentPageIndex.update { it.copy(panel = 0, isLastPanelZoomOutActive = false) } - - if (sortedPanels.isNotEmpty()) { - scrollToPanel( - imageSize = page.panelData.originalImageSize, - screenSize = screenScaleState.areaSize.value, - targetSize = screenScaleState.targetSize.value.toIntSize(), - panel = sortedPanels.first() - ) - } - - } - + readingDirection.drop(1).onEach { + // Simple page reload to ensure correct panel sequence for new direction + launchPageLoad(currentPageIndex.value.page) }.launchIn(stateScope) readerState.booksState @@ -161,6 +145,7 @@ class PanelsReaderState( private suspend fun updateImageState( page: PanelsPage, screenScaleState: ScreenScaleState, + panelIdx: Int ) { val maxPageSize = screenScaleState.areaSize.value val zoomFactor = screenScaleState.transformation.value.scale @@ -211,7 +196,7 @@ class PanelsReaderState( imageResult = null, panelData = null ) - currentPageIndex.value = PageIndex(newPageIndex, 0, false) + currentPageIndex.value = PageIndex(newPageIndex, 0) launchPageLoad(newPageIndex) } @@ -221,6 +206,11 @@ class PanelsReaderState( stateScope.launch { settingsRepository.putPagedReaderReadingDirection(readingDirection) } } + fun onFullPageDisplayModeChange(mode: PanelsFullPageDisplayMode) { + this.fullPageDisplayMode.value = mode + stateScope.launch { settingsRepository.putPanelsFullPageDisplayMode(mode) } + launchPageLoad(currentPageIndex.value.page) + } fun nextPanel() { val pageIndex = currentPageIndex.value @@ -229,42 +219,33 @@ class PanelsReaderState( nextPage() return } - val panelData = currentPage.panelData - val panels = panelData.panels + val panels = currentPage.panelData.panels val panelIndex = pageIndex.panel - if (panels.size <= panelIndex + 1) { - if (panels.isEmpty() || panelData.panelCoversMajorityOfImage || pageIndex.isLastPanelZoomOutActive) { - nextPage() - } else { - scrollToFit() - currentPageIndex.update { it.copy(isLastPanelZoomOutActive = true) } - } - return + if (panelIndex + 1 < panels.size) { + val nextPanel = panels[panelIndex + 1] + val areaSize = screenScaleState.areaSize.value + val targetSize = screenScaleState.targetSize.value.toIntSize() + val imageSize = currentPage.panelData.originalImageSize + scrollToPanel( + imageSize = imageSize, + screenSize = areaSize, + targetSize = targetSize, + panel = nextPanel + ) + currentPageIndex.update { it.copy(panel = panelIndex + 1) } + } else { + nextPage() } - val nextPanel = panels[panelIndex + 1] - val areaSize = screenScaleState.areaSize.value - val targetSize = IntSize( - screenScaleState.targetSize.value.width.roundToInt(), - screenScaleState.targetSize.value.height.roundToInt() - ) - val imageSize = currentPage.panelData.originalImageSize - scrollToPanel( - imageSize = imageSize, - screenSize = areaSize, - targetSize = targetSize, - panel = nextPanel - ) - currentPageIndex.update { it.copy(panel = panelIndex + 1) } } private fun nextPage() { - val currentPageIndex = currentPageIndex.value.page + val pageIdx = currentPageIndex.value.page val currentTransitionPage = transitionPage.value when { - currentPageIndex < pageMetadata.value.size - 1 -> { + pageIdx < pageMetadata.value.size - 1 -> { if (currentTransitionPage != null) this.transitionPage.value = null - else onPageChange(currentPageIndex + 1) + else onPageChange(pageIdx + 1) } currentTransitionPage == null -> { @@ -295,35 +276,30 @@ class PanelsReaderState( val panels = currentPage.panelData.panels val panelIndex = pageIndex.panel - if (panelIndex - 1 < 0) { + if (panelIndex - 1 >= 0) { + val prevPanel = panels[panelIndex - 1] + val areaSize = screenScaleState.areaSize.value + val targetSize = screenScaleState.targetSize.value.toIntSize() + val imageSize = currentPage.panelData.originalImageSize + scrollToPanel( + imageSize = imageSize, + screenSize = areaSize, + targetSize = targetSize, + panel = prevPanel + ) + currentPageIndex.update { it.copy(panel = panelIndex - 1) } + } else { previousPage() - return - } - val previousPage = panels[panelIndex - 1] - val areaSize = screenScaleState.areaSize.value - val targetSize = IntSize( - screenScaleState.targetSize.value.width.roundToInt(), - screenScaleState.targetSize.value.height.roundToInt() - ) - val imageSize = currentPage.panelData.originalImageSize - scrollToPanel( - imageSize = imageSize, - screenSize = areaSize, - targetSize = targetSize, - panel = previousPage - ) - currentPageIndex.update { - it.copy(panel = panelIndex - 1, isLastPanelZoomOutActive = false) } } private fun previousPage() { - val currentPgeIndex = currentPageIndex.value.page + val pageIdx = currentPageIndex.value.page val currentTransitionPage = transitionPage.value when { - currentPgeIndex != 0 -> { + pageIdx != 0 -> { if (currentTransitionPage != null) this.transitionPage.value = null - else onPageChange(currentPgeIndex - 1) + else onPageChange(pageIdx - 1, startAtLast = true) } currentTransitionPage == null -> { @@ -344,23 +320,23 @@ class PanelsReaderState( } } - fun onPageChange(page: Int) { + fun onPageChange(page: Int, startAtLast: Boolean = false) { if (currentPageIndex.value.page == page) return pageChangeFlow.tryEmit(Unit) - launchPageLoad(page) + launchPageLoad(page, startAtLast) } - private fun launchPageLoad(pageIndex: Int) { + private fun launchPageLoad(pageIndex: Int, startAtLast: Boolean = false) { if (pageIndex != currentPageIndex.value.page) { val pageNumber = pageIndex + 1 stateScope.launch { readerState.onProgressChange(pageNumber) } } pageLoadScope.coroutineContext.cancelChildren() - pageLoadScope.launch { doPageLoad(pageIndex) } + pageLoadScope.launch { doPageLoad(pageIndex, startAtLast) } } - private suspend fun doPageLoad(pageIndex: Int) { + private suspend fun doPageLoad(pageIndex: Int, startAtLast: Boolean = false) { val pageMeta = pageMetadata.value[pageIndex] val downloadJob = launchDownload(pageMeta) preloadImagesBetween(pageIndex) @@ -371,29 +347,57 @@ class PanelsReaderState( imageResult = null, panelData = null ) - currentPageIndex.update { PageIndex(pageIndex, 0, false) } + currentPageIndex.update { PageIndex(pageIndex, 0) } transitionPage.value = null screenScaleState.enableOverscrollArea(false) screenScaleState.setZoom(0f, updateBase = true) } val page = downloadJob.await() - val sortedPanelsPage = if (page.panelData != null) { - val sortedPanels = sortPanels( + val sortedPanels = if (page.panelData != null) { + sortPanels( page.panelData.panels, page.panelData.originalImageSize, readingDirection.value ) - page.copy(panelData = page.panelData.copy(panels = sortedPanels)) + } else emptyList() + + val finalPanels = mutableListOf() + if (page.panelData != null) { + val imageSize = page.panelData.originalImageSize + val fullPageRect = ImageRect(0, 0, imageSize.width, imageSize.height) + + // Avoid duplicate view if the AI already detected a full-page panel + val alreadyHasFullPage = sortedPanels.any { it.width >= imageSize.width * 0.95f && it.height >= imageSize.height * 0.95f } + + val mode = fullPageDisplayMode.value + val showFirst = mode == PanelsFullPageDisplayMode.BEFORE || mode == PanelsFullPageDisplayMode.BOTH + val showLast = mode == PanelsFullPageDisplayMode.AFTER || mode == PanelsFullPageDisplayMode.BOTH + + if (sortedPanels.isEmpty()) { + finalPanels.add(fullPageRect) + } else if (alreadyHasFullPage && sortedPanels.size == 1) { + // If it's a splash page (1 large panel), just show it once. + finalPanels.addAll(sortedPanels) + } else { + if (showFirst && !alreadyHasFullPage) finalPanels.add(fullPageRect) + finalPanels.addAll(sortedPanels) + if (showLast && !alreadyHasFullPage) finalPanels.add(fullPageRect) + } + } + + val pageWithInjectedPanels = if (page.panelData != null) { + page.copy(panelData = page.panelData.copy(panels = finalPanels)) } else page val containerSize = screenScaleState.areaSize.value - val scale = getScaleFor(sortedPanelsPage, containerSize) - updateImageState(sortedPanelsPage, scale) - currentPageIndex.update { PageIndex(pageIndex, 0, false) } + val initialPanelIdx = if (startAtLast) (finalPanels.size - 1).coerceAtLeast(0) else 0 + val scale = getScaleFor(pageWithInjectedPanels, containerSize, initialPanelIdx) + + updateImageState(pageWithInjectedPanels, scale, initialPanelIdx) + currentPageIndex.update { PageIndex(pageIndex, initialPanelIdx) } transitionPage.value = null - logger.info { "current page value $sortedPanelsPage" } - currentPage.value = sortedPanelsPage + currentPage.value = pageWithInjectedPanels screenScaleState.enableOverscrollArea(true) screenScaleState.apply(scale) } @@ -407,8 +411,8 @@ class PanelsReaderState( val imageJob = launchDownload(pageMetadata.value[index]) pageLoadScope.launch { val image = imageJob.await() - val scale = getScaleFor(image, screenScaleState.areaSize.value) - updateImageState(image, scale) + val scale = getScaleFor(image, screenScaleState.areaSize.value, 0) + updateImageState(image, scale, 0) } } } @@ -436,7 +440,6 @@ class PanelsReaderState( val imageSize = IntSize(originalImage.width, originalImage.height) val (panels, duration) = measureTimedValue { try { - logger.info { "rf detr before run" } onnxRuntimeRfDetr.detect(originalImage).map { it.boundingBox } } catch (e: OnnxRuntimeException) { return@async PanelsPage( @@ -448,26 +451,10 @@ class PanelsReaderState( } logger.info { "page ${meta.pageNumber} panel detection completed in $duration" } - - val panelsArea = areaOfRects(panels.map { it.toRect() }) - val imageArea = originalImage.width * originalImage.height - val untrimmedRatio = panelsArea / imageArea - - val panelRatio = if (untrimmedRatio < .8f) { - val trim = originalImage.findTrim() - val imageArea = trim.width * trim.height - val ratio = panelsArea / imageArea - logger.info { "trimmed panels area coverage ${ratio * 100}%" } - ratio - } else { - logger.info { "untrimmed panels area coverage ${untrimmedRatio * 100}%" } - untrimmedRatio - } - val panelData = PanelData( panels = panels, originalImageSize = imageSize, - panelCoversMajorityOfImage = panelRatio > .8f + panelCoversMajorityOfImage = false // Placeholder for Phase 2 ) return@async PanelsPage( @@ -482,7 +469,8 @@ class PanelsReaderState( private suspend fun getScaleFor( page: PanelsPage, - containerSize: IntSize + containerSize: IntSize, + panelIdx: Int ): ScreenScaleState { val defaultScale = ScreenScaleState() defaultScale.setAreaSize(containerSize) @@ -499,13 +487,13 @@ class PanelsReaderState( if (panels.isNullOrEmpty()) { scaleState.setZoom(0f, updateBase = true) } else { - val firstPanel = panels.first() + val targetPanel = panels.getOrNull(panelIdx) ?: panels.first() val imageSize = image.getOriginalImageSize().getOrNull() ?: return defaultScale val (offset, zoom) = getPanelOffsetAndZoom( imageSize = imageSize, areaSize = containerSize, targetSize = fitToScreenSize, - panel = firstPanel + panel = targetPanel ) scaleState.setZoom(zoom, updateBase = true) scaleState.setOffset(offset) @@ -515,12 +503,8 @@ class PanelsReaderState( } private fun scrollToFit() { -// val areaSize = screenScaleState.areaSize.value -// val startX = 0 - areaSize.width.toFloat() -// val startY = 0 - areaSize.height.toFloat() screenScaleState.setZoom(0f, updateBase = true) screenScaleState.scrollTo(Offset(0f, 0f)) - } private fun scrollToPanel( @@ -595,7 +579,6 @@ class PanelsReaderState( data class PageIndex( val page: Int, val panel: Int, - val isLastPanelZoomOutActive: Boolean, ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt index e486fc9b..cba58fef 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt @@ -64,6 +64,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType import snd.komelia.settings.model.ReaderType.CONTINUOUS @@ -413,6 +414,18 @@ private fun PanelsModeSettings( label = { Text(strings.forReadingDirection(PagedReadingDirection.LEFT_TO_RIGHT)) } ) } + + val displayMode = state.fullPageDisplayMode.collectAsState().value + Text("Show full page") + FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PanelsFullPageDisplayMode.entries.forEach { mode -> + InputChip( + selected = displayMode == mode, + onClick = { state.onFullPageDisplayModeChange(mode) }, + label = { Text(mode.name) } + ) + } + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt index 59ee117c..cbdf87b8 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt @@ -55,6 +55,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType import snd.komelia.settings.model.ReaderType.CONTINUOUS @@ -173,10 +174,7 @@ fun SettingsSideMenuOverlay( PAGED -> PagedReaderSettingsContent(pagedReaderState) PANELS -> { if (panelsReaderState != null) { - PanelsReaderSettingsContent( - readingDirection = panelsReaderState.readingDirection.collectAsState().value, - onReadingDirectionChange = panelsReaderState::onReadingDirectionChange - ) + PanelsReaderSettingsContent(panelsReaderState) } } @@ -429,10 +427,12 @@ private fun ColumnScope.PagedReaderSettingsContent( @Composable private fun PanelsReaderSettingsContent( - readingDirection: PagedReadingDirection, - onReadingDirectionChange: (PagedReadingDirection) -> Unit, + state: PanelsReaderState ) { val strings = LocalStrings.current.pagedReader + val readingDirection = state.readingDirection.collectAsState().value + val displayMode = state.fullPageDisplayMode.collectAsState().value + Column { DropdownChoiceMenu( @@ -443,11 +443,22 @@ private fun PanelsReaderSettingsContent( options = remember { PagedReadingDirection.entries.map { LabeledEntry(it, strings.forReadingDirection(it)) } }, - onOptionChange = { onReadingDirectionChange(it.value) }, + onOptionChange = { state.onReadingDirectionChange(it.value) }, inputFieldModifier = Modifier.fillMaxWidth(), label = { Text(strings.readingDirection) }, inputFieldColor = MaterialTheme.colorScheme.surfaceVariant ) + + DropdownChoiceMenu( + selectedOption = LabeledEntry(displayMode, displayMode.name), + options = remember { + PanelsFullPageDisplayMode.entries.map { LabeledEntry(it, it.name) } + }, + onOptionChange = { state.onFullPageDisplayModeChange(it.value) }, + inputFieldModifier = Modifier.fillMaxWidth(), + label = { Text("Show full page") }, + inputFieldColor = MaterialTheme.colorScheme.surfaceVariant + ) } } From 5668bb6d4bd7472a8cb1371f9117b70b65bdcdcb Mon Sep 17 00:00:00 2001 From: Eyal Date: Fri, 27 Feb 2026 10:43:54 +0200 Subject: [PATCH 08/35] feat(reader): implement unified smooth pan and zoom for panel navigation - Add synchronized animateTo(Offset, Float) to ScreenScaleState: - Uses linear interpolation to animate offset and zoom simultaneously over 1000ms. - Prevents jerky 'zoom then scroll' jumps during panel transitions. - Update PanelsReaderState to use unified animation for panel-to-panel movement. - Add skipAnimation support to scrollToPanel for instant positioning during initial loads. Co-Authored-By: Gemini CLI --- .../ui/reader/image/ScreenScaleState.kt | 22 +++++++++---------- .../reader/image/panels/PanelsReaderState.kt | 12 ++++++---- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt index 67b054b4..58714ab9 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt @@ -136,23 +136,23 @@ class ScreenScaleState { return -extra - overscroll..extra + overscroll } - fun scrollTo(offset: Offset) { - val coroutineScope = composeScope - check(coroutineScope != null) + fun animateTo(offset: Offset, zoom: Float) { + val coroutineScope = composeScope ?: return scrollJob?.cancel() + zoomJob?.cancel() scrollJob = coroutineScope.launch { - logger.info { "current offset $currentOffset" } - AnimationState( - typeConverter = Offset.VectorConverter, - initialValue = currentOffset, - ).animateTo( - targetValue = offset, + val initialZoom = this@ScreenScaleState.zoom.value + val initialOffset = currentOffset + val targetZoom = zoom.coerceIn(zoomLimits.value) + + AnimationState(initialValue = 0f).animateTo( + targetValue = 1f, animationSpec = tween(durationMillis = 1000) ) { - currentOffset = value + this@ScreenScaleState.zoom.value = initialZoom + (targetZoom - initialZoom) * value + currentOffset = initialOffset + (offset - initialOffset) * value applyLimits() } - logger.info { "scrolled to offset $currentOffset" } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index e84eaaed..f66c02fe 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -503,8 +503,7 @@ class PanelsReaderState( } private fun scrollToFit() { - screenScaleState.setZoom(0f, updateBase = true) - screenScaleState.scrollTo(Offset(0f, 0f)) + screenScaleState.animateTo(Offset(0f, 0f), 0f) } private fun scrollToPanel( @@ -512,6 +511,7 @@ class PanelsReaderState( screenSize: IntSize, targetSize: IntSize, panel: ImageRect, + skipAnimation: Boolean = false, ) { val (offset, zoom) = getPanelOffsetAndZoom( imageSize = imageSize, @@ -519,8 +519,12 @@ class PanelsReaderState( targetSize = targetSize, panel = panel ) - screenScaleState.setZoom(zoom, updateBase = true) - screenScaleState.scrollTo(offset) + if (skipAnimation) { + screenScaleState.setZoom(zoom, updateBase = true) + screenScaleState.setOffset(offset) + } else { + screenScaleState.animateTo(offset, zoom) + } } private fun getPanelOffsetAndZoom( From b16c2470f78e1ed7c1a78719d8672391b14e084b Mon Sep 17 00:00:00 2001 From: Eyal Date: Fri, 27 Feb 2026 11:57:49 +0200 Subject: [PATCH 09/35] feat(reader): implement mode-specific 'Tap to zoom' toggle - Add separate 'Tap to zoom' settings for Paged and Panel reader modes. - Implement database migration (V17) to persist mode-specific tap configuration. - Update persistence layer (Table, Data Class, Repository) to support new settings. - Enhance gesture system: - Conditionally enable double-tap to zoom based on the active reader's configuration. - Disabling double-tap allows for instantaneous single-tap response (faster page/panel turns). - UI Enhancements: - Add 'Tap to zoom' toggle to mobile bottom sheet for Paged and Panels modes. - Add 'Tap to zoom' toggle to desktop side menu for Paged and Panels modes. Co-Authored-By: Gemini CLI --- .../settings/ImageReaderSettingsRepository.kt | 6 ++++++ .../kotlin/snd/komelia/db/ImageReaderSettings.kt | 7 +++++-- .../ReaderSettingsRepositoryWrapper.kt | 16 ++++++++++++++++ .../migrations/app/V17__reader_tap_settings.sql | 2 ++ .../snd/komelia/db/migrations/AppMigrations.kt | 1 + .../ExposedImageReaderSettingsRepository.kt | 4 ++++ .../db/tables/ImageReaderSettingsTable.kt | 2 ++ .../ui/reader/image/common/ReaderContent.kt | 8 +++++--- .../image/continuous/ContinuousReaderContent.kt | 1 + .../ui/reader/image/paged/PagedReaderContent.kt | 2 ++ .../ui/reader/image/paged/PagedReaderState.kt | 7 +++++++ .../reader/image/panels/PanelsReaderContent.kt | 2 ++ .../ui/reader/image/panels/PanelsReaderState.kt | 7 +++++++ .../image/settings/BottomSheetSettingsOverlay.kt | 15 +++++++++++++++ .../ui/reader/image/settings/SettingsSideMenu.kt | 16 ++++++++++++++++ 15 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt index dfd0009f..997d60fd 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt @@ -82,4 +82,10 @@ interface ImageReaderSettingsRepository { fun getPanelsFullPageDisplayMode(): Flow suspend fun putPanelsFullPageDisplayMode(mode: PanelsFullPageDisplayMode) + + fun getPagedReaderTapToZoom(): Flow + suspend fun putPagedReaderTapToZoom(enabled: Boolean) + + fun getPanelReaderTapToZoom(): Flow + suspend fun putPanelReaderTapToZoom(enabled: Boolean) } \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt index d0b2c192..3fec7805 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt @@ -41,5 +41,8 @@ data class ImageReaderSettings( val ortUpscalerDeviceId: Int = 0, val ortUpscalerTileSize: Int = 512, - val panelsFullPageDisplayMode: PanelsFullPageDisplayMode = PanelsFullPageDisplayMode.NONE, -) \ No newline at end of file + val panelsFullPageDisplayMode: PanelsFullPageDisplayMode = PanelsFullPageDisplayMode.NONE, + val pagedReaderTapToZoom: Boolean = true, + val panelReaderTapToZoom: Boolean = true, + ) + \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt index 09f5b950..2dd4c5b3 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt @@ -203,4 +203,20 @@ class ReaderSettingsRepositoryWrapper( override suspend fun putPanelsFullPageDisplayMode(mode: PanelsFullPageDisplayMode) { wrapper.transform { it.copy(panelsFullPageDisplayMode = mode) } } + + override fun getPagedReaderTapToZoom(): Flow { + return wrapper.mapState { it.pagedReaderTapToZoom } + } + + override suspend fun putPagedReaderTapToZoom(enabled: Boolean) { + wrapper.transform { it.copy(pagedReaderTapToZoom = enabled) } + } + + override fun getPanelReaderTapToZoom(): Flow { + return wrapper.mapState { it.panelReaderTapToZoom } + } + + override suspend fun putPanelReaderTapToZoom(enabled: Boolean) { + wrapper.transform { it.copy(panelReaderTapToZoom = enabled) } + } } \ No newline at end of file diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql new file mode 100644 index 00000000..259ad8f0 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql @@ -0,0 +1,2 @@ +ALTER TABLE ImageReaderSettings ADD COLUMN paged_reader_tap_to_zoom BOOLEAN NOT NULL DEFAULT 1; +ALTER TABLE ImageReaderSettings ADD COLUMN panel_reader_tap_to_zoom BOOLEAN NOT NULL DEFAULT 1; diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt index 0b41aef7..39f76fed 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt @@ -23,6 +23,7 @@ class AppMigrations : MigrationResourcesProvider() { "V14__immersive_layout.sql", "V15__new_library_ui.sql", "V16__panel_reader_settings.sql", + "V17__reader_tap_settings.sql", ) override suspend fun getMigration(name: String): ByteArray? { diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt index 37382a1d..32f65316 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt @@ -56,6 +56,8 @@ class ExposedImageReaderSettingsRepository(database: Database) : ExposedReposito ortUpscalerTileSize = it[ImageReaderSettingsTable.ortUpscalerTileSize], panelsFullPageDisplayMode = it[ImageReaderSettingsTable.panelsFullPageDisplayMode] .let { mode -> PanelsFullPageDisplayMode.valueOf(mode) }, + pagedReaderTapToZoom = it[ImageReaderSettingsTable.pagedReaderTapToZoom], + panelReaderTapToZoom = it[ImageReaderSettingsTable.panelReaderTapToZoom], ) } } @@ -88,6 +90,8 @@ class ExposedImageReaderSettingsRepository(database: Database) : ExposedReposito it[ortDeviceId] = settings.ortUpscalerDeviceId it[ortUpscalerTileSize] = settings.ortUpscalerTileSize it[panelsFullPageDisplayMode] = settings.panelsFullPageDisplayMode.name + it[pagedReaderTapToZoom] = settings.pagedReaderTapToZoom + it[panelReaderTapToZoom] = settings.panelReaderTapToZoom } } } diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt index eee419cf..05d6fc79 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt @@ -35,6 +35,8 @@ object ImageReaderSettingsTable : Table("ImageReaderSettings") { val ortUpscalerUserModelPath = text("onnx_runtime_model_path").nullable() val panelsFullPageDisplayMode = text("panels_full_page_display_mode").default("NONE") + val pagedReaderTapToZoom = bool("paged_reader_tap_to_zoom").default(true) + val panelReaderTapToZoom = bool("panel_reader_tap_to_zoom").default(true) override val primaryKey = PrimaryKey(bookId) } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt index a6c2e280..fe0f82fd 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt @@ -197,6 +197,7 @@ fun ReaderControlsOverlay( onSettingsMenuToggle: () -> Unit, contentAreaSize: IntSize, scaleState: ScreenScaleState, + tapToZoom: Boolean, modifier: Modifier, content: @Composable () -> Unit, ) { @@ -222,7 +223,8 @@ fun ReaderControlsOverlay( contentAreaSize, readingDirection, onSettingsMenuToggle, - isSettingsMenuOpen + isSettingsMenuOpen, + tapToZoom ) { detectTapGestures( onTap = { offset -> @@ -233,9 +235,9 @@ fun ReaderControlsOverlay( else -> rightAction() } }, - onDoubleTap = { offset -> + onDoubleTap = if (tapToZoom) { offset -> scaleState.toggleZoom(offset - areaCenter) - } + } else null ) }, contentAlignment = Alignment.Center diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt index 469a918d..66421f6f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt @@ -108,6 +108,7 @@ fun BoxScope.ContinuousReaderContent( onPrevPageClick = { coroutineScope.launch { continuousReaderState.scrollScreenBackward() } }, contentAreaSize = areaSize, scaleState = screenScaleState, + tapToZoom = true, isSettingsMenuOpen = showSettingsMenu, onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, modifier = Modifier.onKeyEvent { event -> diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index 4a920b67..945e5f3d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -74,6 +74,7 @@ fun BoxScope.PagedReaderContent( val currentSpreadIndex = pagedReaderState.currentSpreadIndex.collectAsState().value val layout = pagedReaderState.layout.collectAsState().value val layoutOffset = pagedReaderState.layoutOffset.collectAsState().value + val tapToZoom = pagedReaderState.tapToZoom.collectAsState().value val currentContainerSize = screenScaleState.areaSize.collectAsState().value @@ -118,6 +119,7 @@ fun BoxScope.PagedReaderContent( onPrevPageClick = { coroutineScope.launch { pagedReaderState.previousPage() } }, contentAreaSize = currentContainerSize, scaleState = screenScaleState, + tapToZoom = tapToZoom, isSettingsMenuOpen = showSettingsMenu, onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, modifier = Modifier.onKeyEvent { event -> diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index c4dc5172..19b802b7 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -91,6 +91,7 @@ class PagedReaderState( val layoutOffset = MutableStateFlow(false) val scaleType = MutableStateFlow(LayoutScaleType.SCREEN) val readingDirection = MutableStateFlow(LEFT_TO_RIGHT) + val tapToZoom = MutableStateFlow(true) suspend fun initialize() { layout.value = settingsRepository.getPagedReaderDisplayLayout().first() @@ -100,6 +101,7 @@ class PagedReaderState( KomgaReadingDirection.RIGHT_TO_LEFT -> RIGHT_TO_LEFT else -> settingsRepository.getPagedReaderReadingDirection().first() } + tapToZoom.value = settingsRepository.getPagedReaderTapToZoom().first() screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) @@ -550,6 +552,11 @@ class PagedReaderState( stateScope.launch { settingsRepository.putPagedReaderReadingDirection(readingDirection) } } + fun onTapToZoomChange(enabled: Boolean) { + this.tapToZoom.value = enabled + stateScope.launch { settingsRepository.putPagedReaderTapToZoom(enabled) } + } + private suspend fun calculateScreenScale( pages: List, areaSize: IntSize, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index 19502017..ca45eee0 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -58,6 +58,7 @@ fun BoxScope.PanelsReaderContent( } val page = panelsReaderState.currentPage.collectAsState().value val currentContainerSize = screenScaleState.areaSize.collectAsState().value + val tapToZoom = panelsReaderState.tapToZoom.collectAsState().value val coroutineScope = rememberCoroutineScope() ReaderControlsOverlay( @@ -66,6 +67,7 @@ fun BoxScope.PanelsReaderContent( onPrevPageClick = panelsReaderState::previousPanel, contentAreaSize = currentContainerSize, scaleState = screenScaleState, + tapToZoom = tapToZoom, isSettingsMenuOpen = showSettingsMenu, onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, modifier = Modifier.onKeyEvent { event -> diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index f66c02fe..eaeca2d3 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -97,6 +97,7 @@ class PanelsReaderState( val readingDirection = MutableStateFlow(LEFT_TO_RIGHT) val fullPageDisplayMode = MutableStateFlow(PanelsFullPageDisplayMode.NONE) + val tapToZoom = MutableStateFlow(true) suspend fun initialize() { readingDirection.value = when (readerState.series.value?.metadata?.readingDirection) { @@ -105,6 +106,7 @@ class PanelsReaderState( else -> settingsRepository.getPagedReaderReadingDirection().first() } fullPageDisplayMode.value = settingsRepository.getPanelsFullPageDisplayMode().first() + tapToZoom.value = settingsRepository.getPanelReaderTapToZoom().first() screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) @@ -212,6 +214,11 @@ class PanelsReaderState( launchPageLoad(currentPageIndex.value.page) } + fun onTapToZoomChange(enabled: Boolean) { + this.tapToZoom.value = enabled + stateScope.launch { settingsRepository.putPanelReaderTapToZoom(enabled) } + } + fun nextPanel() { val pageIndex = currentPageIndex.value val currentPage = currentPage.value diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt index cba58fef..720ad06b 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt @@ -309,6 +309,7 @@ private fun PagedModeSettings( ) { val strings = LocalStrings.current.pagedReader val scaleType = pageState.scaleType.collectAsState().value + val tapToZoom = pageState.tapToZoom.collectAsState().value Column { Text(strings.scaleType) @@ -386,6 +387,12 @@ private fun PagedModeSettings( ) } + SwitchWithLabel( + checked = tapToZoom, + onCheckedChange = pageState::onTapToZoomChange, + label = { Text("Tap to zoom") }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) } } @@ -396,6 +403,7 @@ private fun PanelsModeSettings( state: PanelsReaderState, ) { val strings = LocalStrings.current.pagedReader + val tapToZoom = state.tapToZoom.collectAsState().value Column { val readingDirection = state.readingDirection.collectAsState().value @@ -426,6 +434,13 @@ private fun PanelsModeSettings( ) } } + + SwitchWithLabel( + checked = tapToZoom, + onCheckedChange = state::onTapToZoomChange, + label = { Text("Tap to zoom") }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt index cbdf87b8..d53699c1 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt @@ -422,6 +422,14 @@ private fun ColumnScope.PagedReaderSettingsContent( contentPadding = PaddingValues(horizontal = 10.dp) ) } + + val tapToZoom = pageState.tapToZoom.collectAsState().value + SwitchWithLabel( + checked = tapToZoom, + onCheckedChange = pageState::onTapToZoomChange, + label = { Text("Tap to zoom") }, + contentPadding = PaddingValues(horizontal = 10.dp) + ) } } @@ -432,6 +440,7 @@ private fun PanelsReaderSettingsContent( val strings = LocalStrings.current.pagedReader val readingDirection = state.readingDirection.collectAsState().value val displayMode = state.fullPageDisplayMode.collectAsState().value + val tapToZoom = state.tapToZoom.collectAsState().value Column { @@ -459,6 +468,13 @@ private fun PanelsReaderSettingsContent( label = { Text("Show full page") }, inputFieldColor = MaterialTheme.colorScheme.surfaceVariant ) + + SwitchWithLabel( + checked = tapToZoom, + onCheckedChange = state::onTapToZoomChange, + label = { Text("Tap to zoom") }, + contentPadding = PaddingValues(horizontal = 10.dp) + ) } } From 6a7e9d39daafaddb4d0d0ff7d27a80f477b67b99 Mon Sep 17 00:00:00 2001 From: Eyal Date: Fri, 27 Feb 2026 12:22:25 +0200 Subject: [PATCH 10/35] feat(android): upgrade panel detection model to rf-detr-med - Update download URL to point to the higher-accuracy Medium model. - Update detector search path to load 'rf-detr-med.onnx' instead of 'nano'. - Improves panel detection reliability on Android devices. Co-Authored-By: Gemini CLI --- .../kotlin/snd/komelia/image/AndroidPanelDetector.android.kt | 2 +- .../kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/AndroidPanelDetector.android.kt b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/AndroidPanelDetector.android.kt index a3129afc..1017d7cf 100644 --- a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/AndroidPanelDetector.android.kt +++ b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/AndroidPanelDetector.android.kt @@ -19,7 +19,7 @@ class AndroidPanelDetector( private val logger = KotlinLogging.logger { } override fun getModelPath(): String? { - val path = dataDir.resolve("rf-detr-nano.onnx") + val path = dataDir.resolve("rf-detr-med.onnx") logger.info { "panel detector path string $path" } val exists = path.exists() return if (exists) path.toString() else null diff --git a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt index 1d17f88d..61a6cc1b 100644 --- a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt +++ b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt @@ -24,7 +24,7 @@ import kotlin.io.path.inputStream import kotlin.io.path.outputStream private const val panelDetectionModelLink = - "https://github.com/Snd-R/komelia-onnxruntime/releases/download/model/rf-detr-nano.onnx.zip" + "https://github.com/Snd-R/komelia-onnxruntime/releases/download/model/rf-detr-med.onnx.zip" class AndroidOnnxModelDownloader( private val updateClient: UpdateClient, @@ -41,7 +41,7 @@ class AndroidOnnxModelDownloader( return flow { emit(UpdateProgress(0, 0, panelDetectionModelLink)) - val archiveFile = createTempFile("rf-detr-nano.onnx.zip") + val archiveFile = createTempFile("rf-detr-med.onnx.zip") archiveFile.toFile().deleteOnExit() appNotifications.runCatchingToNotifications { From e6e0ac6677728eec6474f08cd74cdf5f0af2f97d Mon Sep 17 00:00:00 2001 From: Eyal Date: Fri, 27 Feb 2026 14:02:47 +0200 Subject: [PATCH 11/35] feat(reader): smooth animated transitions for Paged and Panel modes --- .../reader/image/paged/PagedReaderContent.kt | 22 +++++-- .../ui/reader/image/paged/PagedReaderState.kt | 23 ++++++- .../image/panels/PanelsReaderContent.kt | 64 +++++++++++++++++-- .../reader/image/panels/PanelsReaderState.kt | 56 ++++++++++++---- 4 files changed, 144 insertions(+), 21 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index 945e5f3d..94d53a08 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -1,5 +1,6 @@ package snd.komelia.ui.reader.image.paged +import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -88,9 +89,23 @@ fun BoxScope.PagedReaderContent( screenScaleState.setScrollOrientation(Orientation.Horizontal, readingDirection == RIGHT_TO_LEFT) } - LaunchedEffect(currentSpreadIndex) { - if (pagerState.currentPage != currentSpreadIndex) { - pagerState.scrollToPage(currentSpreadIndex) + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { + pagedReaderState.pageNavigationEvents.collect { event -> + if (pagerState.currentPage != event.pageIndex) { + when (event) { + is PagedReaderState.PageNavigationEvent.Animated -> { + pagerState.animateScrollToPage( + page = event.pageIndex, + animationSpec = tween(durationMillis = 1000) + ) + } + + is PagedReaderState.PageNavigationEvent.Immediate -> { + pagerState.scrollToPage(event.pageIndex) + } + } + } } } @@ -112,7 +127,6 @@ fun BoxScope.PagedReaderContent( } } - val coroutineScope = rememberCoroutineScope() ReaderControlsOverlay( readingDirection = layoutDirection, onNexPageClick = { coroutineScope.launch { pagedReaderState.nextPage() } }, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index 19b802b7..768a9a43 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -93,6 +93,8 @@ class PagedReaderState( val readingDirection = MutableStateFlow(LEFT_TO_RIGHT) val tapToZoom = MutableStateFlow(true) + val pageNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1) + suspend fun initialize() { layout.value = settingsRepository.getPagedReaderDisplayLayout().first() scaleType.value = settingsRepository.getPagedReaderScaleType().first() @@ -232,7 +234,7 @@ class PagedReaderState( ) currentSpreadIndex.value = newSpreadIndex - loadPage(newSpreadIndex) + jumpToPage(newSpreadIndex) } fun nextPage() { @@ -303,17 +305,36 @@ class PagedReaderState( loadPage(lastPageIndex) } + fun jumpToPage(page: Int) { + if (currentSpreadIndex.value == page) return + pageChangeFlow.tryEmit(Unit) + val pageNumber = pageSpreads.value[page].last().pageNumber + stateScope.launch { readerState.onProgressChange(pageNumber) } + currentSpreadIndex.value = page + pageNavigationEvents.tryEmit(PageNavigationEvent.Immediate(page)) + + pageLoadScope.coroutineContext.cancelChildren() + pageLoadScope.launch { loadSpread(page) } + } + private fun loadPage(spreadIndex: Int) { if (spreadIndex != currentSpreadIndex.value) { val pageNumber = pageSpreads.value[spreadIndex].last().pageNumber stateScope.launch { readerState.onProgressChange(pageNumber) } currentSpreadIndex.value = spreadIndex + pageNavigationEvents.tryEmit(PageNavigationEvent.Animated(spreadIndex)) } pageLoadScope.coroutineContext.cancelChildren() pageLoadScope.launch { loadSpread(spreadIndex) } } + sealed interface PageNavigationEvent { + val pageIndex: Int + data class Animated(override val pageIndex: Int) : PageNavigationEvent + data class Immediate(override val pageIndex: Int) : PageNavigationEvent + } + private suspend fun loadSpread(loadSpreadIndex: Int) { val loadRange = getSpreadLoadRange(loadSpreadIndex) val currentSpreadMetadata = pageSpreads.value[loadSpreadIndex] diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index ca45eee0..adf426b4 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -1,5 +1,6 @@ package snd.komelia.ui.reader.image.panels +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -7,10 +8,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,6 +31,7 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import snd.komelia.image.ReaderImageResult import snd.komelia.settings.model.PagedReadingDirection import snd.komelia.settings.model.PagedReadingDirection.LEFT_TO_RIGHT import snd.komelia.settings.model.PagedReadingDirection.RIGHT_TO_LEFT @@ -33,6 +40,7 @@ import snd.komelia.ui.reader.image.common.PagedReaderHelpDialog import snd.komelia.ui.reader.image.common.ReaderControlsOverlay import snd.komelia.ui.reader.image.common.ReaderImageContent import snd.komelia.ui.reader.image.common.ScalableContainer +import snd.komelia.ui.reader.image.paged.PagedReaderState.PageNavigationEvent import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart @@ -56,10 +64,41 @@ fun BoxScope.PanelsReaderContent( LEFT_TO_RIGHT -> LayoutDirection.Ltr RIGHT_TO_LEFT -> LayoutDirection.Rtl } - val page = panelsReaderState.currentPage.collectAsState().value + val metadata = panelsReaderState.pageMetadata.collectAsState().value + val currentPageIndex = panelsReaderState.currentPageIndex.collectAsState().value val currentContainerSize = screenScaleState.areaSize.collectAsState().value val tapToZoom = panelsReaderState.tapToZoom.collectAsState().value + val pagerState = rememberPagerState( + initialPage = currentPageIndex.page, + pageCount = { metadata.size } + ) + + LaunchedEffect(Unit) { + panelsReaderState.pageNavigationEvents.collect { event -> + if (pagerState.currentPage != event.pageIndex) { + when (event) { + is PageNavigationEvent.Animated -> { + pagerState.animateScrollToPage( + page = event.pageIndex, + animationSpec = tween(durationMillis = 1000) + ) + } + + is PageNavigationEvent.Immediate -> { + pagerState.scrollToPage(event.pageIndex) + } + } + } + } + } + + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage < metadata.size) { + panelsReaderState.onPageChange(pagerState.currentPage) + } + } + val coroutineScope = rememberCoroutineScope() ReaderControlsOverlay( readingDirection = layoutDirection, @@ -86,11 +125,26 @@ fun BoxScope.PanelsReaderContent( if (transitionPage != null) { TransitionPage(transitionPage) } else { - page?.let { - Box(contentAlignment = Alignment.Center) { - ReaderImageContent(page.imageResult) + if (metadata.isNotEmpty()) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + reverseLayout = readingDirection == RIGHT_TO_LEFT, + modifier = Modifier.fillMaxSize(), + key = { if (it < metadata.size) metadata[it].pageNumber else it } + ) { pageIdx -> + if (pageIdx >= metadata.size) return@HorizontalPager + val pageMeta = metadata[pageIdx] + + val imageResultState = remember(pageMeta) { mutableStateOf(null) } + LaunchedEffect(pageMeta) { + imageResultState.value = panelsReaderState.getImage(pageMeta) + } + + Box(contentAlignment = Alignment.Center) { + ReaderImageContent(imageResultState.value) + } } -// SinglePageLayout(page) } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index eaeca2d3..fa84066b 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -47,6 +47,7 @@ import snd.komelia.ui.reader.image.BookState import snd.komelia.ui.reader.image.PageMetadata import snd.komelia.ui.reader.image.ReaderState import snd.komelia.ui.reader.image.ScreenScaleState +import snd.komelia.ui.reader.image.paged.PagedReaderState.PageNavigationEvent import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart @@ -99,6 +100,8 @@ class PanelsReaderState( val fullPageDisplayMode = MutableStateFlow(PanelsFullPageDisplayMode.NONE) val tapToZoom = MutableStateFlow(true) + val pageNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1) + suspend fun initialize() { readingDirection.value = when (readerState.series.value?.metadata?.readingDirection) { KomgaReadingDirection.LEFT_TO_RIGHT -> LEFT_TO_RIGHT @@ -144,6 +147,17 @@ class PanelsReaderState( imageCache.invalidateAll() } + suspend fun getImage(page: PageMetadata): ReaderImageResult { + val pageId = page.toPageId() + val cached = imageCache.get(pageId) + return if (cached != null && !cached.isCancelled) { + cached.await().imageResult ?: ReaderImageResult.Error(Exception("Image result is null")) + } else { + val job = launchDownload(page) + job.await().imageResult ?: ReaderImageResult.Error(Exception("Image result is null")) + } + } + private suspend fun updateImageState( page: PanelsPage, screenScaleState: ScreenScaleState, @@ -200,7 +214,7 @@ class PanelsReaderState( ) currentPageIndex.value = PageIndex(newPageIndex, 0) - launchPageLoad(newPageIndex) + jumpToPage(newPageIndex) } fun onReadingDirectionChange(readingDirection: PagedReadingDirection) { @@ -327,33 +341,49 @@ class PanelsReaderState( } } + fun jumpToPage(page: Int) { + if (currentPageIndex.value.page == page) return + pageChangeFlow.tryEmit(Unit) + val pageNumber = page + 1 + stateScope.launch { readerState.onProgressChange(pageNumber) } + pageNavigationEvents.tryEmit(PageNavigationEvent.Immediate(page)) + launchPageLoad(page) + } + fun onPageChange(page: Int, startAtLast: Boolean = false) { if (currentPageIndex.value.page == page) return pageChangeFlow.tryEmit(Unit) - launchPageLoad(page, startAtLast) + pageNavigationEvents.tryEmit(PageNavigationEvent.Animated(page)) + launchPageLoad(page, startAtLast, isAnimated = true) } - private fun launchPageLoad(pageIndex: Int, startAtLast: Boolean = false) { + private fun launchPageLoad(pageIndex: Int, startAtLast: Boolean = false, isAnimated: Boolean = false) { if (pageIndex != currentPageIndex.value.page) { val pageNumber = pageIndex + 1 stateScope.launch { readerState.onProgressChange(pageNumber) } } pageLoadScope.coroutineContext.cancelChildren() - pageLoadScope.launch { doPageLoad(pageIndex, startAtLast) } + pageLoadScope.launch { doPageLoad(pageIndex, startAtLast, isAnimated) } } - private suspend fun doPageLoad(pageIndex: Int, startAtLast: Boolean = false) { + private suspend fun doPageLoad(pageIndex: Int, startAtLast: Boolean = false, isAnimated: Boolean = false) { val pageMeta = pageMetadata.value[pageIndex] val downloadJob = launchDownload(pageMeta) preloadImagesBetween(pageIndex) if (downloadJob.isActive) { - currentPage.value = PanelsPage( - metadata = pageMeta, - imageResult = null, - panelData = null - ) + currentPage.update { + it?.copy( + metadata = pageMeta, + imageResult = null, + panelData = null + ) ?: PanelsPage( + metadata = pageMeta, + imageResult = null, + panelData = null + ) + } currentPageIndex.update { PageIndex(pageIndex, 0) } transitionPage.value = null screenScaleState.enableOverscrollArea(false) @@ -406,7 +436,11 @@ class PanelsReaderState( transitionPage.value = null currentPage.value = pageWithInjectedPanels screenScaleState.enableOverscrollArea(true) - screenScaleState.apply(scale) + if (isAnimated) { + screenScaleState.animateTo(scale.transformation.value.offset, scale.zoom.value) + } else { + screenScaleState.apply(scale) + } } private fun preloadImagesBetween(pageIndex: Int) { From 0ed5f0337cb4e156b26d7bc0afc700f150b9924b Mon Sep 17 00:00:00 2001 From: Eyal Date: Fri, 27 Feb 2026 19:59:37 +0200 Subject: [PATCH 12/35] feat(reader): implement density-aware spring physics for consistent transitions - Centralize navigation physics in ReaderAnimation.navSpringSpec(density). - Update ScreenScaleState to store and use display density for camera transitions. - Normalize page sliding and camera movement tempo across devices (phone vs tablet). - Ensure smooth side-by-side page sliding in Panel mode transitions by rendering neighbors. - Resolves sluggish feel on tablets and jarring snap on phones. Co-Authored-By: Gemini CLI --- .../komelia/ui/reader/image/ScreenScaleState.kt | 9 ++++++++- .../ui/reader/image/common/ReaderAnimation.kt | 17 +++++++++++++++++ .../ui/reader/image/paged/PagedReaderContent.kt | 12 ++++++++++-- .../reader/image/panels/PanelsReaderContent.kt | 7 ++++++- .../ui/reader/image/panels/PanelsReaderState.kt | 5 +++++ 5 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt index 58714ab9..f6e6cc06 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt @@ -1,5 +1,6 @@ package snd.komelia.ui.reader.image +import snd.komelia.ui.reader.image.common.ReaderAnimation import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.core.Spring @@ -75,6 +76,12 @@ class ScreenScaleState { @Volatile private var enableOverscrollArea = false + private var density = 1f + + fun setDensity(density: Float) { + this.density = density + } + fun scaleFor100PercentZoom() = max( areaSize.value.width.toFloat() / targetSize.value.width, @@ -147,7 +154,7 @@ class ScreenScaleState { AnimationState(initialValue = 0f).animateTo( targetValue = 1f, - animationSpec = tween(durationMillis = 1000) + animationSpec = ReaderAnimation.navSpringSpec(density) ) { this@ScreenScaleState.zoom.value = initialZoom + (targetZoom - initialZoom) * value currentOffset = initialOffset + (offset - initialOffset) * value diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt new file mode 100644 index 00000000..eb3e7528 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt @@ -0,0 +1,17 @@ +package snd.komelia.ui.reader.image.common + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring + +object ReaderAnimation { + /** + * Unified spring spec for all manual navigation (taps, arrow keys). + * Damping: NoBouncy (1.0f) for a clean, professional finish. + * Normalized by density to ensure consistent physical speed across devices. + */ + fun navSpringSpec(density: Float) = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = (Spring.StiffnessLow * (2f / density)) + .coerceIn(Spring.StiffnessVeryLow..Spring.StiffnessMedium) + ) +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index 94d53a08..8f1b50e8 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -46,6 +46,8 @@ import snd.komelia.ui.reader.image.common.PagedReaderHelpDialog import snd.komelia.ui.reader.image.common.ReaderControlsOverlay import snd.komelia.ui.reader.image.common.ReaderImageContent import snd.komelia.ui.reader.image.common.ScalableContainer +import androidx.compose.ui.platform.LocalDensity +import snd.komelia.ui.reader.image.common.ReaderAnimation import snd.komelia.ui.reader.image.paged.PagedReaderState.Page import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd @@ -66,6 +68,9 @@ fun BoxScope.PagedReaderContent( PagedReaderHelpDialog(onDismissRequest = { onShowHelpDialogChange(false) }) } + val density = LocalDensity.current.density + LaunchedEffect(density) { screenScaleState.setDensity(density) } + val readingDirection = pagedReaderState.readingDirection.collectAsState().value val layoutDirection = when (readingDirection) { LEFT_TO_RIGHT -> LayoutDirection.Ltr @@ -97,7 +102,7 @@ fun BoxScope.PagedReaderContent( is PagedReaderState.PageNavigationEvent.Animated -> { pagerState.animateScrollToPage( page = event.pageIndex, - animationSpec = tween(durationMillis = 1000) + animationSpec = ReaderAnimation.navSpringSpec(density) ) } @@ -122,7 +127,10 @@ fun BoxScope.PagedReaderContent( if (!isGestureInProgress && !isFlinging) { val pageOffset = pagerState.currentPageOffsetFraction if (abs(pageOffset) > 0.001f) { - pagerState.animateScrollToPage(pagerState.currentPage) + pagerState.animateScrollToPage( + page = pagerState.currentPage, + animationSpec = ReaderAnimation.navSpringSpec(density) + ) } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index adf426b4..4a4d2cc4 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -37,6 +38,7 @@ import snd.komelia.settings.model.PagedReadingDirection.LEFT_TO_RIGHT import snd.komelia.settings.model.PagedReadingDirection.RIGHT_TO_LEFT import snd.komelia.ui.reader.image.ScreenScaleState import snd.komelia.ui.reader.image.common.PagedReaderHelpDialog +import snd.komelia.ui.reader.image.common.ReaderAnimation import snd.komelia.ui.reader.image.common.ReaderControlsOverlay import snd.komelia.ui.reader.image.common.ReaderImageContent import snd.komelia.ui.reader.image.common.ScalableContainer @@ -59,6 +61,9 @@ fun BoxScope.PanelsReaderContent( PagedReaderHelpDialog(onDismissRequest = { onShowHelpDialogChange(false) }) } + val density = LocalDensity.current.density + LaunchedEffect(density) { screenScaleState.setDensity(density) } + val readingDirection = panelsReaderState.readingDirection.collectAsState().value val layoutDirection = when (readingDirection) { LEFT_TO_RIGHT -> LayoutDirection.Ltr @@ -81,7 +86,7 @@ fun BoxScope.PanelsReaderContent( is PageNavigationEvent.Animated -> { pagerState.animateScrollToPage( page = event.pageIndex, - animationSpec = tween(durationMillis = 1000) + animationSpec = ReaderAnimation.navSpringSpec(density) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index fa84066b..e2343c89 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -147,6 +147,11 @@ class PanelsReaderState( imageCache.invalidateAll() } + private var density = 1f + fun setDensity(density: Float) { + this.density = density + } + suspend fun getImage(page: PageMetadata): ReaderImageResult { val pageId = page.toPageId() val cached = imageCache.get(pageId) From 55e273cfea7a65ed25e4c339881bb3a21251522f Mon Sep 17 00:00:00 2001 From: Eyal Date: Fri, 27 Feb 2026 20:24:04 +0200 Subject: [PATCH 13/35] fix(paged): resolve initial book loading hang - Remove incorrect early return in jumpToPage() that prevented loading on startup. - Matches the fix applied to Panel mode for consistent startup behavior. Co-Authored-By: Gemini CLI --- .../kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index 768a9a43..3b886c40 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -306,7 +306,6 @@ class PagedReaderState( } fun jumpToPage(page: Int) { - if (currentSpreadIndex.value == page) return pageChangeFlow.tryEmit(Unit) val pageNumber = pageSpreads.value[page].last().pageNumber stateScope.launch { readerState.onProgressChange(pageNumber) } From f3ecacd18abe8129f17fc833eac1ae580e935657 Mon Sep 17 00:00:00 2001 From: eserero Date: Fri, 27 Feb 2026 20:30:05 +0200 Subject: [PATCH 14/35] reader: feature reader improvements and additional documentation --- DISABLE_DOUBLE_TAP_PLAN.md | 70 +++++++++ PANEL_NAVIGATION_SYSTEM.md | 52 +++++++ PANEL_SETTINGS_DETAILED_PLAN.md | 140 ++++++++++++++++++ PANEL_VIEWER_IMPLEMENTATION_PLAN.md | 57 +++++++ READER_MOTION_PLAN.md | 55 +++++++ .../reader/image/panels/PanelsReaderState.kt | 2 +- 6 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 DISABLE_DOUBLE_TAP_PLAN.md create mode 100644 PANEL_NAVIGATION_SYSTEM.md create mode 100644 PANEL_SETTINGS_DETAILED_PLAN.md create mode 100644 PANEL_VIEWER_IMPLEMENTATION_PLAN.md create mode 100644 READER_MOTION_PLAN.md diff --git a/DISABLE_DOUBLE_TAP_PLAN.md b/DISABLE_DOUBLE_TAP_PLAN.md new file mode 100644 index 00000000..432fd459 --- /dev/null +++ b/DISABLE_DOUBLE_TAP_PLAN.md @@ -0,0 +1,70 @@ +# Implementation Plan: Disable Double-Tap to Zoom + +## Goal +Allow users to disable the double-tap gesture to zoom. This eliminates the system's wait-time for a second tap, making the single-tap (for page/panel turns) feel instantaneous. + +--- + +## 1. Database Migration +**File**: `komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql` +```sql +ALTER TABLE ImageReaderSettings ADD COLUMN tap_to_zoom BOOLEAN NOT NULL DEFAULT 1; +``` + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt` +- Add `"V17__reader_tap_settings.sql"` to the `migrations` list. + +--- + +## 2. Persistence Layer Updates + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt` +```kotlin + val tapToZoom = bool("tap_to_zoom").default(true) +``` + +**File**: `komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt` +```kotlin + val tapToZoom: Boolean = true, +``` + +**File**: `komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt` +```kotlin + fun getTapToZoom(): Flow + suspend fun putTapToZoom(enabled: Boolean) +``` + +**File**: `komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt` +- Implement `getTapToZoom` and `putTapToZoom`. + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt` +- Map `tapToZoom` in `get()` and `save()`. + +--- + +## 3. State Management +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ReaderState.kt` (since it applies to multiple modes) +- Load `tapToZoom` from repository. +- Provide a `onTapToZoomChange(Boolean)` handler. + +--- + +## 4. UI Layer (Settings) +Add a "Tap to zoom" toggle to the reading mode settings for both **Paged** and **Panels** modes. + +**Files**: +- `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt` (Mobile) +- `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt` (Desktop) + +--- + +## 5. Gesture Integration +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt` +- Modify `ReaderControlsOverlay` to accept a `tapToZoom: Boolean` parameter. +- Update `detectTapGestures`: +```kotlin +detectTapGestures( + onTap = { ... }, + onDoubleTap = if (tapToZoom) { offset -> ... } else null +) +``` diff --git a/PANEL_NAVIGATION_SYSTEM.md b/PANEL_NAVIGATION_SYSTEM.md new file mode 100644 index 00000000..30750083 --- /dev/null +++ b/PANEL_NAVIGATION_SYSTEM.md @@ -0,0 +1,52 @@ +# Panel-by-Panel Navigation System: High-Level Overview + +This document describes how the panel detection and navigation system works in the Comic Reader. + +## 1. Detection Engine (The "Brain") +The system uses a machine learning model (`RF-Detr`) to identify panels within a page spread. + +- **Model Format**: ONNX (Open Neural Network Exchange). +- **Runtime**: `OnnxRuntimeRfDetr` (implemented via ONNX Runtime). +- **Core Interface**: `KomeliaPanelDetector` handles the model lifecycle, including initialization and inference. +- **Input**: A `KomeliaImage` object (the raw decoded page). +- **Output**: A list of `DetectResult` objects, each containing: + - `classId`: The type of detection (usually represents a panel). + - `confidence`: How certain the model is about the detection. + - `boundingBox`: An `ImageRect` (Left, Top, Right, Bottom) in the coordinate space of the original image. + +## 2. Pre-Processing & Sorting (`PanelsReaderState.kt`) +Once the model returns raw coordinates, the reader performs several logical steps to make them "readable": + +- **Sorting**: The raw list of panels is sorted based on the current **Reading Direction** (Left-to-Right or Right-to-Left). This ensures that "Next Panel" follows the logical flow of the comic. +- **Coverage Analysis**: The system calculates the total area covered by detected panels versus the total image area. + - If panels cover > 80% of the image, the system flags it. + - If detection coverage is low, it might use a "Find Trim" fallback to ignore blank margins. +- **Caching**: Panel coordinates are cached alongside the image data in a `Cache` (using `cache4k`) to avoid re-running inference on every page turn. + +## 3. UI Navigation Logic +The `PanelsReaderState` manages the state of which panel is currently "active" using a `PageIndex` (page number + panel index). + +### The "Next" Command (`nextPanel()`) +When the user requests the next panel: +1. **Check Current Page**: If there's another panel in the sorted list for the current page, it scrolls to it. +2. **Boundary Logic**: + - If it was the last panel, it first zooms out to show the full page (`scrollToFit`). + - If the user clicks again while zoomed out, it moves to the next physical page. +3. **Calculation**: It uses `getPanelOffsetAndZoom()` to convert the panel's bounding box into a specific `Offset` and `Zoom` level for the `ScreenScaleState`. + +### Screen Centering Math +The most critical part of the system is the coordinate transformation: +- **Scale Calculation**: It determines the maximum scale that allows the panel to fit entirely within the screen dimensions without being clipped. +- **Offset Transformation**: It calculates the precise X and Y translation needed to place the center of the panel bounding box exactly in the center of the viewport. + +## 4. Execution +The transition is handled by `ScreenScaleState.scrollTo(offset)` and `setZoom()`. Because `ScreenScaleState` uses animated state holders (via `AnimationState`), the move from one panel to the next is a smooth, kinetic slide and zoom effect rather than a jump. + +--- + +## Technical Summary of the Flow: +1. **Load Page** → **Run AI Inference** → **Get Bounding Boxes**. +2. **Sort Boxes** based on LTR/RTL settings. +3. **Calculate Viewport** (Zoom + Offset) to isolate the box. +4. **Animate `ScreenScaleState`** to the calculated viewport. +5. **Update Index** to track progress within the page. diff --git a/PANEL_SETTINGS_DETAILED_PLAN.md b/PANEL_SETTINGS_DETAILED_PLAN.md new file mode 100644 index 00000000..1aa66249 --- /dev/null +++ b/PANEL_SETTINGS_DETAILED_PLAN.md @@ -0,0 +1,140 @@ +# Detailed Implementation Plan: Panel Reader "Show Full Page" Settings + +## 1. Domain Model +**File**: `komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt` +```kotlin +package snd.komelia.settings.model + +enum class PanelsFullPageDisplayMode { + NONE, + BEFORE, + AFTER, + BOTH +} +``` + +## 2. Database Migration +**File**: `komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql` +```sql +ALTER TABLE ImageReaderSettings ADD COLUMN panels_full_page_display_mode TEXT NOT NULL DEFAULT 'NONE'; +``` + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt` +```kotlin + private val migrations = listOf( + // ... existing + "V15__new_library_ui.sql", + "V16__panel_reader_settings.sql" + ) +``` + +## 3. Persistence Layer Updates + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt` +```kotlin + val panelsFullPageDisplayMode = text("panels_full_page_display_mode").default("NONE") +``` + +**File**: `komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt` +```kotlin + val panelsFullPageDisplayMode: PanelsFullPageDisplayMode = PanelsFullPageDisplayMode.NONE, +``` + +**File**: `komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt` +```kotlin + fun getPanelsFullPageDisplayMode(): Flow + suspend fun putPanelsFullPageDisplayMode(mode: PanelsFullPageDisplayMode) +``` + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt` +- **In `get()` mapping**: +```kotlin + panelsFullPageDisplayMode = PanelsFullPageDisplayMode.valueOf(it[ImageReaderSettingsTable.panelsFullPageDisplayMode]), +``` +- **In `save()` mapping**: +```kotlin + it[panelsFullPageDisplayMode] = settings.panelsFullPageDisplayMode.name +``` + +## 4. State Management +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt` +- **Properties**: +```kotlin + val fullPageDisplayMode = MutableStateFlow(PanelsFullPageDisplayMode.NONE) +``` +- **In `initialize()`**: +```kotlin + fullPageDisplayMode.value = settingsRepository.getPanelsFullPageDisplayMode().first() +``` +- **Logic in `doPageLoad()`**: +```kotlin + val mode = fullPageDisplayMode.value + val showFirst = mode == PanelsFullPageDisplayMode.BEFORE || mode == PanelsFullPageDisplayMode.BOTH + val showLast = mode == PanelsFullPageDisplayMode.AFTER || mode == PanelsFullPageDisplayMode.BOTH + + if (showFirst && !alreadyHasFullPage) finalPanels.add(fullPageRect) + finalPanels.addAll(sortedPanels) + if (showLast && !alreadyHasFullPage) finalPanels.add(fullPageRect) +``` +- **Change Handler**: +```kotlin + fun onFullPageDisplayModeChange(mode: PanelsFullPageDisplayMode) { + this.fullPageDisplayMode.value = mode + stateScope.launch { settingsRepository.putPanelsFullPageDisplayMode(mode) } + launchPageLoad(currentPageIndex.value.page) + } +``` + +## 5. UI Components + +### Desktop UI +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt` +- Update `PanelsReaderSettingsContent` signature and body: +```kotlin +@Composable +private fun PanelsReaderSettingsContent( + state: PanelsReaderState +) { + val strings = LocalStrings.current.pagedReader + val readingDirection = state.readingDirection.collectAsState().value + val displayMode = state.fullPageDisplayMode.collectAsState().value + + Column { + DropdownChoiceMenu(...) // Reading Direction + + DropdownChoiceMenu( + selectedOption = LabeledEntry(displayMode, displayMode.name), // TODO: Add strings + options = PanelsFullPageDisplayMode.entries.map { LabeledEntry(it, it.name) }, + onOptionChange = { state.onFullPageDisplayModeChange(it.value) }, + label = { Text("Show full page") }, + inputFieldModifier = Modifier.fillMaxWidth(), + inputFieldColor = MaterialTheme.colorScheme.surfaceVariant + ) + } +} +``` + +### Mobile UI +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt` +- Update `PanelsModeSettings`: +```kotlin +@Composable +private fun PanelsModeSettings(state: PanelsReaderState) { + val displayMode = state.fullPageDisplayMode.collectAsState().value + Column { + Text("Reading direction") + // ... existing chips + + Text("Show full page") + FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PanelsFullPageDisplayMode.entries.forEach { mode -> + InputChip( + selected = displayMode == mode, + onClick = { state.onFullPageDisplayModeChange(mode) }, + label = { Text(mode.name) } + ) + } + } + } +} +``` diff --git a/PANEL_VIEWER_IMPLEMENTATION_PLAN.md b/PANEL_VIEWER_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..9c8084bc --- /dev/null +++ b/PANEL_VIEWER_IMPLEMENTATION_PLAN.md @@ -0,0 +1,57 @@ +# Implementation Plan: Panel-by-Panel "Full Page" Sequence + +## Goal +Implement a consistent "Full Page -> Panels -> Full Page" sequence for the Panel Reader. +- When entering a new page, show the full page first (Optional). +- Navigate through all detected panels. +- After the last panel, show the full page again (Optional). +- Next click moves to the next physical page. + +--- + +## 1. List-Based Injection (`PanelsReaderState.kt`) +**Change**: Inject a "Full Page" rectangle at the beginning and/or end of the detected panel list based on settings. + +- **New State Properties** (Phase 2 Settings placeholder): + - `showFullPageFirst: Boolean` (Default: `true`) + - `showFullPageLast: Boolean` (Default: `true`) +- **Location**: `doPageLoad()` method. +- **Logic**: + ```kotlin + val fullPageRect = ImageRect(0, 0, imageSize.width, imageSize.height) + val panels = mutableListOf() + + if (showFullPageFirst) panels.add(fullPageRect) + panels.addAll(sortedPanels) + if (showFullPageLast) panels.add(fullPageRect) + ``` +- **Optimization**: If a detected panel is already >95% of the page size, skip injection for that specific slot to avoid "double-viewing" splash pages. + +## 2. Simplify Navigation Logic +**Change**: Remove the "Smart" zoom-out logic from `nextPanel()` and `previousPanel()`. + +- **`nextPanel()`**: + - If `panelIndex + 1 < panels.size`, move to `panelIndex + 1`. + - Else, call `nextPage()`. +- **`previousPanel()`**: + - If `panelIndex - 1 >= 0`, move to `panelIndex - 1`. + - Else, call `previousPage()`. + +## 3. Directional Page Changes +**Change**: Ensure that moving backwards starts the user at the *end* of the previous page. + +- **`nextPage()`**: Calls `onPageChange(currentPageIndex + 1)` (starts at index 0). +- **`previousPage()`**: Calls `onPageChange(currentPageIndex - 1, startAtLast = true)`. +- **`launchPageLoad`**: Update signature to `launchPageLoad(pageIndex: Int, startAtLast: Boolean = false)`. + +--- + +## 4. Technical Steps +1. **Refactor `PageIndex`**: Remove the `isLastPanelZoomOutActive` flag. +2. **Update `launchPageLoad`**: Accept a `startAtLast: Boolean` flag. +3. **Modify `doPageLoad`**: + - Perform detection. + - Sort panels. + - Apply Injection logic (First/Last). + - Set `currentPageIndex` to `0` or `panels.lastIndex` based on `startAtLast`. +4. **Update `previousPage()`**: Call `onPageChange(index - 1, startAtLast = true)`. diff --git a/READER_MOTION_PLAN.md b/READER_MOTION_PLAN.md new file mode 100644 index 00000000..958dbd0d --- /dev/null +++ b/READER_MOTION_PLAN.md @@ -0,0 +1,55 @@ +# Reader Motion System Plan: Spring Physics Migration + +## 1. Objective +To replace the current fixed-duration (1000ms) page and panel transitions with a unified, physics-based system. This will ensure consistent timing across different screen sizes (phones vs. tablets) and provide a more responsive, premium feel aligned with Material 3 Motion principles. + +## 2. Reasoning +* **Scale Independence**: Fixed-duration `tween` animations cover distance at different physical speeds depending on screen size. On a large tablet, a 1-second slide feels much slower than on a phone. Spring physics use tension and stiffness to calculate velocity based on distance, maintaining a consistent "tempo." +* **Responsiveness**: Springs naturally handle "velocity handoff." If a user triggers a new navigation while an old one is finishing, the spring animation inherits the current momentum instead of cutting or restarting abruptly. +* **OS Setting Resilience**: Fixed `tween` durations are directly multiplied by the Android "Animation Duration Scale" developer setting. If this is set high, reader navigation becomes painfully slow. Springs are more resilient to these multipliers. +* **M3 Compliance**: Material 3 recommends spring-based motion for expressive, natural interactions like page flipping and camera movement. + +## 3. Architecture: Centralized Motion Spec +To prevent disjointed animation speeds, we will create a centralized motion configuration. + +**New File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt` +```kotlin +package snd.komelia.ui.reader.image.common + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring + +object ReaderAnimation { + /** + * Unified spring spec for all manual navigation (taps, arrow keys). + * Damping: NoBouncy (1.0f) for a clean, professional finish. + * Stiffness: MediumLow (approx 400ms feel) for a snappy, premium response. + */ + val NavSpringSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ) +} +``` + +## 4. Implementation Details + +### A. ScreenScaleState.kt (Camera Movement) +**Function**: `animateTo(offset: Offset, zoom: Float)` +* **Change**: Replace `tween(durationMillis = 1000)` with `ReaderAnimation.NavSpringSpec`. +* **Effect**: Panel-to-panel transitions and "Fit to Screen" zooms will use physical momentum. + +### B. PagedReaderContent.kt (Paged Mode) +**Function**: `pagerState.animateScrollToPage(...)` inside navigation event collection. +* **Change**: Replace `tween(durationMillis = 1000)` with `ReaderAnimation.NavSpringSpec`. +* **Effect**: Tapping left/right will slide the page into place using the same force as the camera. + +### C. PanelsReaderContent.kt (Panel Mode) +**Function**: `pagerState.animateScrollToPage(...)` inside navigation event collection. +* **Change**: Replace `tween(durationMillis = 1000)` with `ReaderAnimation.NavSpringSpec`. +* **Effect**: Moving to the next physical page while in panel mode will be perfectly synchronized with the camera's zoom/pan spring. + +## 5. Verification +1. **Phone vs. Tablet**: Perform side-by-side tests to ensure the "tempo" of the page turns feels identical despite the physical distance difference. +2. **Continuous Tapping**: Tap quickly through several panels/pages to verify the animation doesn't "stutter" or reset awkwardly. +3. **RTL Direction**: Verify springs correctly pull the page in the right direction for RTL reading. diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index e2343c89..a0f42873 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -347,10 +347,10 @@ class PanelsReaderState( } fun jumpToPage(page: Int) { - if (currentPageIndex.value.page == page) return pageChangeFlow.tryEmit(Unit) val pageNumber = page + 1 stateScope.launch { readerState.onProgressChange(pageNumber) } + currentPageIndex.update { it.copy(page = page) } pageNavigationEvents.tryEmit(PageNavigationEvent.Immediate(page)) launchPageLoad(page) } From 70d0c49152c11d89f7a715c03b2847543316cfe3 Mon Sep 17 00:00:00 2001 From: eserero Date: Fri, 27 Feb 2026 23:55:32 +0200 Subject: [PATCH 15/35] fix(ui): improve immersive detail view layout and interaction - Fix ImmersiveDetailFab layout by removing weights and adding a leading Spacer for correct right-alignment. - Synchronize BookViewModel with ImmersiveBookContent pager to ensure correct book context for download and navigation. - Refactor ImmersiveDetailScaffold nested scroll logic to ensure a hard stop at the top when expanding the card. - Prevent state drift in card scrolling by consuming upward velocity and delta in onPreScroll/onPreFling. Co-Authored-By: Gemini CLI --- .../kotlin/snd/komelia/ui/book/BookScreen.kt | 7 +++-- .../snd/komelia/ui/book/BookViewModel.kt | 12 ++++++-- .../ui/book/immersive/ImmersiveBookContent.kt | 5 ++++ .../ui/common/immersive/ImmersiveDetailFab.kt | 14 ++++----- .../immersive/ImmersiveDetailScaffold.kt | 30 +++++++++---------- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt index f1d0828d..dc03a464 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt @@ -95,16 +95,17 @@ class BookScreen( }, readLists = vm.readListsState.readLists, onReadListClick = { navigator.push(ReadListScreen(it.id)) }, - onReadListBookPress = { book, readList -> - if (book.id != bookId) navigator.push( + onReadListBookPress = { listBook, readList -> + if (listBook.id != book.id) navigator.push( bookScreen( - book = book, + book = listBook, bookSiblingsContext = BookSiblingsContext.ReadList(readList.id) ) ) }, cardWidth = vm.cardWidth.collectAsState().value, onSeriesClick = { seriesId -> navigator.push(SeriesScreen(seriesId)) }, + onBookChange = vm::setCurrentBook, ) BackPressHandler { onBackPress(navigator, book.seriesId) } return diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt index 6cba5963..9219c297 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt @@ -44,7 +44,7 @@ import snd.komga.client.sse.KomgaEvent.ReadProgressDeleted class BookViewModel( book: KomeliaBook?, - private val bookId: KomgaBookId, + bookId: KomgaBookId, private val bookApi: KomgaBookApi, private val notifications: AppNotifications, private val komgaEvents: SharedFlow, @@ -57,6 +57,7 @@ class BookViewModel( var library by mutableStateOf(null) private set val book = MutableStateFlow(book) + private val currentBookId = MutableStateFlow(bookId) private val reloadEventsEnabled = MutableStateFlow(true) private val reloadJobsFlow = MutableSharedFlow(1, 0, DROP_OLDEST) @@ -116,7 +117,7 @@ class BookViewModel( private suspend fun loadBook() { notifications.runCatchingToNotifications { mutableState.value = Loading - val loadedBook = bookApi.getOne(bookId) + val loadedBook = bookApi.getOne(currentBookId.value) book.value = loadedBook } .onSuccess { mutableState.value = Success(Unit) } @@ -138,6 +139,12 @@ class BookViewModel( readListsState.startKomgaEventHandler() } + fun setCurrentBook(book: KomeliaBook) { + this.book.value = book + this.currentBookId.value = book.id + loadLibrary() + } + fun onBookDownload() { screenModelScope.launch { book.value?.let { taskEmitter.downloadBook(it.id) } @@ -152,6 +159,7 @@ class BookViewModel( private fun startKomgaEventListener() { komgaEvents.onEach { event -> + val bookId = currentBookId.value when (event) { is BookChanged, is BookAdded -> if (event.bookId == bookId) reloadJobsFlow.tryEmit(Unit) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt index a09cfbcb..480add7b 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -93,6 +93,7 @@ fun ImmersiveBookContent( onReadListBookPress: (KomeliaBook, KomgaReadList) -> Unit, cardWidth: Dp, onSeriesClick: (KomgaSeriesId) -> Unit, + onBookChange: (KomeliaBook) -> Unit = {}, ) { val initialPage = remember(siblingBooks, book) { siblingBooks.indexOfFirst { it.id == book.id }.coerceAtLeast(0) @@ -119,6 +120,10 @@ fun ImmersiveBookContent( siblingBooks.getOrNull(pagerState.settledPage) ?: book } + LaunchedEffect(selectedBook) { + onBookChange(selectedBook) + } + var showDownloadConfirmationDialog by remember { mutableStateOf(false) } var sharedExpanded by remember { mutableStateOf(false) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt index c5081333..c052bc92 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -54,14 +55,14 @@ fun ImmersiveDetailFab( .fillMaxWidth() .padding(horizontal = 16.dp) ) { + Spacer(modifier = Modifier.weight(1f)) + if (showReadActions) { - // Split pill: Read Now (2/3) | Incognito (1/3) + // Split pill: Read Now | Incognito Surface( shape = CircleShape, color = pillBackground, - modifier = Modifier - .weight(1f) - .height(56.dp) + modifier = Modifier.height(56.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -72,7 +73,6 @@ fun ImmersiveDetailFab( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .weight(2f) .fillMaxHeight() .clickable(onClick = onReadClick) .padding(horizontal = 20.dp) @@ -98,7 +98,6 @@ fun ImmersiveDetailFab( Box( contentAlignment = Alignment.Center, modifier = Modifier - .weight(1f) .fillMaxHeight() .clickable(onClick = onReadIncognitoClick) .padding(horizontal = 16.dp) @@ -111,9 +110,6 @@ fun ImmersiveDetailFab( } } } - } else { - // Spacer to push download FAB to the right - Box(modifier = Modifier.weight(1f)) } // Download FAB diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index b3ce317f..f2543f39 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -203,36 +203,36 @@ fun ImmersiveDetailScaffold( val nestedScrollConnection = remember(state) { object : NestedScrollConnection { - var preScrollConsumedY = 0f - var lastGestureWasExpand = false - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset val delta = available.y return if (delta < 0 && currentOffset > 0f) { - val consumed = state.dispatchRawDelta(delta) - preScrollConsumedY = consumed - if (consumed != 0f) lastGestureWasExpand = true - Offset(0f, consumed) + state.dispatchRawDelta(delta) + Offset(0f, delta) } else { - preScrollConsumedY = 0f Offset.Zero } } override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - val innerConsumedY = consumed.y - preScrollConsumedY - if (innerConsumedY != 0f) - innerScrollPx = (innerScrollPx - innerConsumedY).coerceAtLeast(0f) + innerScrollPx = (innerScrollPx - consumed.y).coerceAtLeast(0f) val delta = available.y - return if (delta > 0 && source == NestedScrollSource.UserInput) { + return if (delta > 0 && source == NestedScrollSource.UserInput && innerScrollPx <= 0f) { val cardConsumed = state.dispatchRawDelta(delta) - if (cardConsumed != 0f) lastGestureWasExpand = false Offset(0f, cardConsumed) } else Offset.Zero } + override suspend fun onPreFling(available: Velocity): Velocity { + val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset + if (available.y < 0f && currentOffset > 0f) { + state.settle(-1000f) + return available + } + return Velocity.Zero + } + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset if (currentOffset <= 0f || currentOffset >= collapsedOffsetPx) return Velocity.Zero @@ -243,8 +243,8 @@ fun ImmersiveDetailScaffold( state.settle(available.y) available } - available.y < 0f || lastGestureWasExpand -> { - // Upward fling OR last drag was expanding: snap to EXPANDED + available.y < 0f -> { + // Upward fling: snap to EXPANDED state.settle(-1000f) available } From 995703b2574ac64d1612bb867116968ad10fe924 Mon Sep 17 00:00:00 2001 From: eserero Date: Sat, 28 Feb 2026 00:16:49 +0200 Subject: [PATCH 16/35] refactor(ui): simplify immersive scaffold and fix state restoration - Remove manual scroll tracking (innerScrollPx) and overlay thumbnail from ImmersiveDetailScaffold. - Move thumbnails directly into card content for Books, Series, and Oneshots to ensure perfect scroll synchronization. - Persist card expansion state (isExpanded) in ViewModels to ensure correct state restoration when navigating back. - Fix layout issue where extra space was reserved for thumbnails when the card was downsized. Co-Authored-By: Gemini CLI --- .../kotlin/snd/komelia/ui/book/BookScreen.kt | 2 + .../snd/komelia/ui/book/BookViewModel.kt | 1 + .../ui/book/immersive/ImmersiveBookContent.kt | 33 +++++++++++-- .../immersive/ImmersiveDetailScaffold.kt | 49 ++----------------- .../snd/komelia/ui/oneshot/OneshotScreen.kt | 2 + .../komelia/ui/oneshot/OneshotViewModel.kt | 4 ++ .../immersive/ImmersiveOneshotContent.kt | 30 +++++++++++- .../snd/komelia/ui/series/SeriesScreen.kt | 2 + .../snd/komelia/ui/series/SeriesViewModel.kt | 1 + .../immersive/ImmersiveSeriesContent.kt | 31 +++++++++++- 10 files changed, 100 insertions(+), 55 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt index dc03a464..1079c354 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt @@ -106,6 +106,8 @@ class BookScreen( cardWidth = vm.cardWidth.collectAsState().value, onSeriesClick = { seriesId -> navigator.push(SeriesScreen(seriesId)) }, onBookChange = vm::setCurrentBook, + initiallyExpanded = vm.isExpanded, + onExpandChange = { vm.isExpanded = it } ) BackPressHandler { onBackPress(navigator, book.seriesId) } return diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt index 9219c297..bebc75a5 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt @@ -58,6 +58,7 @@ class BookViewModel( private set val book = MutableStateFlow(book) private val currentBookId = MutableStateFlow(bookId) + var isExpanded by mutableStateOf(false) private val reloadEventsEnabled = MutableStateFlow(true) private val reloadJobsFlow = MutableSharedFlow(1, 0, DROP_OLDEST) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt index 480add7b..40fa8115 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp @@ -63,6 +64,7 @@ import snd.komelia.DefaultDateTimeFormats.localDateTimeFormat import snd.komelia.image.coil.BookDefaultThumbnailRequest import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.ui.book.BookInfoColumn +import snd.komelia.ui.common.images.ThumbnailImage import snd.komelia.ui.common.immersive.ImmersiveDetailFab import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold import snd.komelia.ui.common.menus.BookActionsMenu @@ -94,6 +96,8 @@ fun ImmersiveBookContent( cardWidth: Dp, onSeriesClick: (KomgaSeriesId) -> Unit, onBookChange: (KomeliaBook) -> Unit = {}, + initiallyExpanded: Boolean, + onExpandChange: (Boolean) -> Unit, ) { val initialPage = remember(siblingBooks, book) { siblingBooks.indexOfFirst { it.id == book.id }.coerceAtLeast(0) @@ -125,7 +129,6 @@ fun ImmersiveBookContent( } var showDownloadConfirmationDialog by remember { mutableStateOf(false) } - var sharedExpanded by remember { mutableStateOf(false) } val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current @@ -171,8 +174,8 @@ fun ImmersiveBookContent( coverKey = pageBook.id.value, cardColor = accentColor, immersive = true, - initiallyExpanded = sharedExpanded, - onExpandChange = { sharedExpanded = it }, + initiallyExpanded = initiallyExpanded, + onExpandChange = onExpandChange, topBarContent = {}, // Fixed overlay handles this fabContent = {}, // Fixed overlay handles this cardContent = { expandFraction -> @@ -205,12 +208,32 @@ fun ImmersiveBookContent( .fillMaxWidth() .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) .padding( - start = thumbnailOffset + 16.dp, + start = 16.dp, end = 16.dp, top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, ) ) { - Column { + if (expandFraction > 0.01f) { + Box( + modifier = Modifier + .padding(top = (thumbnailTopGap - 8.dp) * expandFraction) + .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } + ) { + ThumbnailImage( + data = coverData, + cacheKey = pageBook.id.value, + crossfade = false, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = thumbnailHeight) + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + Column( + modifier = Modifier.padding(start = thumbnailOffset) + ) { val headlineFs = MaterialTheme.typography.headlineMedium.fontSize.value // Line 1: Series · #N (2/3 headlineMedium, bold) — tappable link Row( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index f2543f39..01230176 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -2,7 +2,6 @@ package snd.komelia.ui.common.immersive import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationVector import androidx.compose.animation.core.CubicBezierEasing @@ -39,15 +38,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -183,8 +179,6 @@ fun ImmersiveDetailScaffold( val cardOffsetPx = if (state.offset.isNaN()) collapsedOffsetPx else state.offset val expandFraction = (1f - cardOffsetPx / collapsedOffsetPx).coerceIn(0f, 1f) - var innerScrollPx by rememberSaveable { mutableFloatStateOf(0f) } - // Snap already-composed pages (e.g. adjacent in a pager) when the parent changes the // shared expand state. Skips the snap if the card is already in the right position. LaunchedEffect(initiallyExpanded) { @@ -198,14 +192,13 @@ fun ImmersiveDetailScaffold( LaunchedEffect(state.currentValue) { savedExpanded = state.currentValue == CardDragValue.EXPANDED onExpandChange(savedExpanded) - if (state.currentValue == CardDragValue.COLLAPSED) innerScrollPx = 0f } val nestedScrollConnection = remember(state) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset val delta = available.y + val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset return if (delta < 0 && currentOffset > 0f) { state.dispatchRawDelta(delta) Offset(0f, delta) @@ -215,10 +208,8 @@ fun ImmersiveDetailScaffold( } override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - innerScrollPx = (innerScrollPx - consumed.y).coerceAtLeast(0f) - val delta = available.y - return if (delta > 0 && source == NestedScrollSource.UserInput && innerScrollPx <= 0f) { + return if (delta > 0 && source == NestedScrollSource.UserInput) { val cardConsumed = state.dispatchRawDelta(delta) Offset(0f, cardConsumed) } else Offset.Zero @@ -293,7 +284,7 @@ fun ImmersiveDetailScaffold( .fillMaxWidth() .height(screenHeight) .nestedScroll(nestedScrollConnection) - .anchoredDraggable(state, Orientation.Vertical, enabled = innerScrollPx <= 0f) + .anchoredDraggable(state, Orientation.Vertical) .shadow(elevation = 2.dp, shape = cardShape) .clip(cardShape) .background(backgroundColor) @@ -314,40 +305,6 @@ fun ImmersiveDetailScaffold( } } - // Layer 3: Thumbnail — fades in as card expands, moves with the card - // Positioned at card top + drag handle (28dp) + small gap (8dp), left-aligned with 16dp margin - val thumbAlpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) - // Clip container: top edge sits at status bar bottom; clipToBounds hides thumbnail above it - Box( - modifier = Modifier - .fillMaxWidth() - .height(screenHeight - statusBarDp) - .offset { IntOffset(0, statusBarPx.roundToInt()) } - .clipToBounds() - ) { - Box( - modifier = Modifier - .offset { - IntOffset( - x = with(density) { 16.dp.toPx() }.roundToInt(), - y = (cardOffsetPx + with(density) { (28.dp + 20.dp).toPx() } - innerScrollPx - statusBarPx) - .roundToInt() - ) - } - .graphicsLayer { alpha = thumbAlpha } - ) { - ThumbnailImage( - data = coverData, - cacheKey = coverKey, - crossfade = false, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(width = 110.dp, height = (110.dp / 0.703f)) - .clip(RoundedCornerShape(8.dp)) - ) - } - } - // Layer 5: Top bar Box(modifier = Modifier.fillMaxWidth().then(uiEnterExitModifier).statusBarsPadding()) { topBarContent() diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt index 52c7be32..6092a25f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt @@ -118,6 +118,8 @@ class OneshotScreen( onBookDownload = vm::onBookDownload, cardWidth = vm.cardWidth.collectAsState().value, onBackClick = { onBackPress(navigator, vmSeries.libraryId) }, + initiallyExpanded = vm.isExpanded, + onExpandChange = { vm.isExpanded = it } ) BackPressHandler { onBackPress(navigator, vmSeries.libraryId) } return diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt index 0e801284..a6344411 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt @@ -1,5 +1,8 @@ package snd.komelia.ui.oneshot +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope @@ -67,6 +70,7 @@ class OneshotViewModel( val series = MutableStateFlow(series) val library = MutableStateFlow(null) val book = MutableStateFlow(book) + var isExpanded by mutableStateOf(false) val bookMenuActions = BookMenuActions(bookApi, notifications, screenModelScope, taskEmitter) val cardWidth = settingsRepository.getCardWidth().map { it.dp } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt index 28bac5a3..167fbd7e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.rounded.MoreVert @@ -40,8 +41,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp @@ -58,6 +61,7 @@ import snd.komelia.image.coil.SeriesDefaultThumbnailRequest import kotlin.math.roundToInt import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.ui.book.BookInfoColumn +import snd.komelia.ui.common.images.ThumbnailImage import snd.komelia.ui.common.immersive.ImmersiveDetailFab import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold import snd.komelia.ui.common.menus.BookMenuActions @@ -95,6 +99,8 @@ fun ImmersiveOneshotContent( onBookDownload: () -> Unit, cardWidth: Dp, onBackClick: () -> Unit, + initiallyExpanded: Boolean, + onExpandChange: (Boolean) -> Unit, ) { var showDownloadConfirmationDialog by remember { mutableStateOf(false) } @@ -131,6 +137,8 @@ fun ImmersiveOneshotContent( coverKey = series.id.value, cardColor = accentColor, immersive = true, + initiallyExpanded = initiallyExpanded, + onExpandChange = onExpandChange, topBarContent = {}, // Fixed overlay handles this fabContent = {}, // Fixed overlay handles this cardContent = { expandFraction -> @@ -163,12 +171,30 @@ fun ImmersiveOneshotContent( .fillMaxWidth() .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) .padding( - start = thumbnailOffset + 16.dp, + start = 16.dp, end = 16.dp, top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, ) ) { - Column { + if (expandFraction > 0.01f) { + Box( + modifier = Modifier + .padding(top = (thumbnailTopGap - 8.dp) * expandFraction) + .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } + ) { + ThumbnailImage( + data = SeriesDefaultThumbnailRequest(series.id), + cacheKey = series.id.value, + crossfade = false, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = thumbnailHeight) + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + Column(modifier = Modifier.padding(start = thumbnailOffset)) { val headlineFs = MaterialTheme.typography.headlineMedium.fontSize.value // Book title (2/3 headlineMedium, bold) Text( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt index 91487cbd..950ee049 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt @@ -107,6 +107,8 @@ class SeriesScreen( }, onBackClick = { onBackPress(navigator, series.libraryId) }, onDownload = vm::onDownload, + initiallyExpanded = vm.isExpanded, + onExpandChange = { vm.isExpanded = it } ) BackPressHandler { onBackPress(navigator, series.libraryId) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesViewModel.kt index b692219e..6ff50e87 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesViewModel.kt @@ -61,6 +61,7 @@ class SeriesViewModel( val series = MutableStateFlow(series?.withSortedTags()) val library = MutableStateFlow(null) var currentTab by mutableStateOf(defaultTab) + var isExpanded by mutableStateOf(false) val cardWidth = settingsRepository.getCardWidth().map { it.dp } .stateIn(screenModelScope, Eagerly, defaultCardWidth.dp) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt index 06e280bf..fcb85603 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt @@ -37,6 +37,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -47,9 +49,12 @@ import snd.komelia.ui.LoadState import snd.komelia.ui.collection.SeriesCollectionsContent import snd.komelia.ui.collection.SeriesCollectionsState import snd.komelia.ui.common.components.AppFilterChipDefaults +import snd.komelia.ui.common.images.ThumbnailImage import snd.komelia.ui.common.immersive.ImmersiveDetailFab import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold import snd.komelia.ui.common.menus.SeriesActionsMenu +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import snd.komelia.ui.common.menus.SeriesMenuActions import snd.komelia.ui.common.menus.bulk.BooksBulkActionsContent import snd.komelia.ui.common.menus.bulk.BottomPopupBulkActionsPanel @@ -88,6 +93,8 @@ fun ImmersiveSeriesContent( onSeriesClick: (KomgaSeries) -> Unit, onBackClick: () -> Unit, onDownload: () -> Unit, + initiallyExpanded: Boolean, + onExpandChange: (Boolean) -> Unit, ) { val booksLoadState = booksState.state.collectAsState().value val booksData = remember(booksLoadState) { @@ -143,6 +150,8 @@ fun ImmersiveSeriesContent( coverKey = series.id.value, cardColor = accentColor, immersive = true, + initiallyExpanded = initiallyExpanded, + onExpandChange = onExpandChange, topBarContent = { if (selectionMode) { BulkActionsContainer( @@ -237,12 +246,30 @@ fun ImmersiveSeriesContent( .fillMaxWidth() .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) .padding( - start = thumbnailOffset + 16.dp, + start = 16.dp, end = 16.dp, top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, ) ) { - Column { + if (expandFraction > 0.01f) { + Box( + modifier = Modifier + .padding(top = (thumbnailTopGap - 8.dp) * expandFraction) + .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } + ) { + ThumbnailImage( + data = SeriesDefaultThumbnailRequest(series.id), + cacheKey = series.id.value, + crossfade = false, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = thumbnailHeight) + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + Column(modifier = Modifier.padding(start = thumbnailOffset)) { Text( text = series.metadata.title, style = MaterialTheme.typography.headlineMedium.copy( From 0530e2b79c502a5fe7bbcb22864aa7a85a38e86d Mon Sep 17 00:00:00 2001 From: eserero Date: Sat, 28 Feb 2026 00:59:44 +0200 Subject: [PATCH 17/35] style(ui): align immersive views with Material 3 typography and elevated card specs - Align typography in Series, Book, and Oneshot immersive screens with Material 3 tokens (headlineSmall, titleMedium, labelSmall). - Replace manually scaled headlines and hardcoded 10sp text with standard M3 scale steps. - Upgrade card in ImmersiveDetailScaffold to Material 3 Elevated Card specs (6.dp elevation). - Adjust scaffold background to 'surface' to provide visual contrast for the elevated card shadow. Co-Authored-By: Gemini CLI --- .../ui/book/immersive/ImmersiveBookContent.kt | 14 ++++++-------- .../ui/common/immersive/ImmersiveDetailScaffold.kt | 4 ++-- .../oneshot/immersive/ImmersiveOneshotContent.kt | 10 ++++------ .../ui/series/immersive/ImmersiveSeriesContent.kt | 7 ++----- .../komelia/ui/series/view/SeriesDescriptionRow.kt | 4 ++-- 5 files changed, 16 insertions(+), 23 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt index 40fa8115..697b14ed 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -234,8 +234,7 @@ fun ImmersiveBookContent( Column( modifier = Modifier.padding(start = thumbnailOffset) ) { - val headlineFs = MaterialTheme.typography.headlineMedium.fontSize.value - // Line 1: Series · #N (2/3 headlineMedium, bold) — tappable link + // Line 1: Series · #N (headlineSmall, bold) — tappable link Row( modifier = Modifier .clip(RoundedCornerShape(4.dp)) @@ -246,8 +245,7 @@ fun ImmersiveBookContent( ) { Text( text = "${pageBook.seriesTitle} · #${pageBook.metadata.number}", - style = MaterialTheme.typography.headlineMedium.copy( - fontSize = (headlineFs * 2f / 3f).sp, + style = MaterialTheme.typography.headlineSmall.copy( fontWeight = FontWeight.Bold, ), color = MaterialTheme.colorScheme.primary, @@ -259,15 +257,15 @@ fun ImmersiveBookContent( tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), ) } - // Line 2: Book title (bodySmall) — only if different from series title + // Line 2: Book title (titleMedium) — only if different from series title if (pageBook.metadata.title != pageBook.seriesTitle) { Text( text = pageBook.metadata.title, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 2.dp), ) } - // Line 3: Writers (year) — 10 sp + // Line 3: Writers (year) — labelSmall val writers = remember(pageBook.metadata.authors) { pageBook.metadata.authors .filter { it.role.lowercase() == "writer" } @@ -284,7 +282,7 @@ fun ImmersiveBookContent( if (writersYearText.isNotEmpty()) { Text( text = writersYearText, - fontSize = 10.sp, + style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 2.dp), ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index 01230176..3ef49bb0 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -253,7 +253,7 @@ fun ImmersiveDetailScaffold( val statusBarDp = LocalRawStatusBarHeight.current val statusBarPx = with(density) { statusBarDp.toPx() } - Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { // Layer 1: Cover image — fades out as card expands // Extends by the card corner radius so it fills behind the rounded corners @@ -285,7 +285,7 @@ fun ImmersiveDetailScaffold( .height(screenHeight) .nestedScroll(nestedScrollConnection) .anchoredDraggable(state, Orientation.Vertical) - .shadow(elevation = 2.dp, shape = cardShape) + .shadow(elevation = 6.dp, shape = cardShape) .clip(cardShape) .background(backgroundColor) ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt index 167fbd7e..d6f3ee95 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -195,16 +195,14 @@ fun ImmersiveOneshotContent( } Column(modifier = Modifier.padding(start = thumbnailOffset)) { - val headlineFs = MaterialTheme.typography.headlineMedium.fontSize.value - // Book title (2/3 headlineMedium, bold) + // Book title (headlineSmall, bold) Text( text = book.metadata.title, - style = MaterialTheme.typography.headlineMedium.copy( - fontSize = (headlineFs * 2f / 3f).sp, + style = MaterialTheme.typography.headlineSmall.copy( fontWeight = FontWeight.Bold, ), ) - // Writers (year) — 10 sp + // Writers (year) — labelSmall val writers = remember(book.metadata.authors) { book.metadata.authors .filter { it.role.lowercase() == "writer" } @@ -221,7 +219,7 @@ fun ImmersiveOneshotContent( if (writersYearText.isNotEmpty()) { Text( text = writersYearText, - fontSize = 10.sp, + style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 2.dp), ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt index fcb85603..8ebd880e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt @@ -272,10 +272,7 @@ fun ImmersiveSeriesContent( Column(modifier = Modifier.padding(start = thumbnailOffset)) { Text( text = series.metadata.title, - style = MaterialTheme.typography.headlineMedium.copy( - fontSize = (MaterialTheme.typography.headlineMedium.fontSize.value * 2f / 3f).sp, - fontWeight = FontWeight.Bold, - ), + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), ) val writers = remember(series.booksMetadata.authors) { series.booksMetadata.authors @@ -290,7 +287,7 @@ fun ImmersiveSeriesContent( if (writersYearText.isNotEmpty()) { Text( text = writersYearText, - fontSize = 10.sp, + style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 2.dp), ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt index 9b7b20c9..278e982a 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt @@ -64,7 +64,7 @@ fun SeriesDescriptionRow( ) { if (showReleaseYear && releaseDate != null) - Text("Release Year: ${releaseDate.year}", fontSize = 10.sp) + Text("Release Year: ${releaseDate.year}", style = MaterialTheme.typography.labelSmall) FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { ElevatedButton( @@ -141,7 +141,7 @@ fun SeriesDescriptionRow( if (alternateTitles.isNotEmpty()) { SelectionContainer { Column { - Text("Alternative titles", fontWeight = FontWeight.Bold) + Text("Alternative titles", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) alternateTitles.forEach { Row { Text( From d4b5b399dc939d0976e3fb2466dd15e0ab2d9076 Mon Sep 17 00:00:00 2001 From: eserero Date: Sat, 28 Feb 2026 02:31:00 +0200 Subject: [PATCH 18/35] style(ui): align core navigation, FABs, and menus with Material 3 specification - Refactor MainScreen mobile layout to use a single Scaffold and M3 NavigationBar. - Update immersive detail FABs to use standard M3 Extended FAB and standard FAB with squircle shapes. - Standardize visual hierarchy in all action menus with leading icons and M3 typography. - Style destructive menu actions with 'error' color tokens and remove manual hover overrides. - Update toolbar and item card triggers to use rounded icon variants (MoreVert). - Clean up redundant window inset spacers and hardcoded list padding. Co-Authored-By: Gemini CLI --- MATERIAL_3_ALIGNMENT_PLAN.md | 145 ++++++++++++++ .../kotlin/snd/komelia/ui/MainScreen.kt | 188 +++++++----------- .../komelia/ui/common/cards/BookItemCard.kt | 7 +- .../komelia/ui/common/cards/SeriesItemCard.kt | 4 +- .../ui/common/immersive/ImmersiveDetailFab.kt | 104 +++------- .../komelia/ui/common/itemlist/SeriesLists.kt | 5 +- .../ui/common/menus/BookActionsMenu.kt | 77 +++---- .../ui/common/menus/CollectionActionsMenu.kt | 28 ++- .../ui/common/menus/LibraryActionsMenu.kt | 75 +++---- .../ui/common/menus/OneshotActionsMenu.kt | 63 +++--- .../ui/common/menus/ReadListActionsMenu.kt | 29 ++- .../ui/common/menus/SeriesActionsMenu.kt | 83 ++++---- .../kotlin/snd/komelia/ui/home/HomeContent.kt | 4 +- .../ui/settings/MobileSettingsScreen.kt | 2 - 14 files changed, 447 insertions(+), 367 deletions(-) create mode 100644 MATERIAL_3_ALIGNMENT_PLAN.md diff --git a/MATERIAL_3_ALIGNMENT_PLAN.md b/MATERIAL_3_ALIGNMENT_PLAN.md new file mode 100644 index 00000000..0afdb3d5 --- /dev/null +++ b/MATERIAL_3_ALIGNMENT_PLAN.md @@ -0,0 +1,145 @@ +# Material 3 Alignment Plan: Navigation, FABs, and Menus - COMPLETED Feb 2026 + +This document outlines the plan to align the core navigation, Floating Action Buttons (FABs), and Dropdown Menus across the Komelia application with the Material 3 (M3) specification. + +## 1. Bottom Navigation Bar Alignment (New UI Mode Only) - DONE + +### Target Files & Functions +1. **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt`** + * **Modify**: `MobileLayout(navigator, vm)` to use a single `Scaffold` for both UI modes. + * **Delete**: `PillBottomNavigationBar(...)` and `PillNavItem(...)` composables. + * **Create**: `AppNavigationBar(navigator, toggleLibrariesDrawer)` using M3 `NavigationBar`. +2. **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt`** + * **Modify**: `Content()` to remove the bottom `Spacer` and `windowInsetsBottomHeight`. +3. **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt`** + * **Modify**: `SeriesLazyCardGrid(...)` to review and likely remove/reduce the `65.dp` bottom content padding in `LazyVerticalGrid` to rely on `Scaffold` padding. +4. **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt`** + * **Modify**: `DisplayContent(...)` to remove the manual `50.dp` bottom padding in `LazyColumn` and `LazyVerticalGrid`. + +### Implementation Details + +#### 1.1 Unify `MobileLayout` in `MainScreen.kt` +Refactor `MobileLayout` to use a single `Scaffold` regardless of the UI mode. This ensures consistent anchoring and proper window inset handling via `PaddingValues`. + +* **Scaffold structure**: + ```kotlin + val isImmersiveScreen = navigator.lastItem is SeriesScreen || + navigator.lastItem is BookScreen || + navigator.lastItem is OneshotScreen + + Scaffold( + bottomBar = { + if (!isImmersiveScreen) { + if (useNewLibraryUI) { + AppNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } } + ) + } else { + StandardBottomNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + modifier = Modifier + ) + } + } + } + ) { paddingValues -> + ModalNavigationDrawer( + drawerState = vm.navBarState, + drawerContent = { LibrariesNavBar(vm, navigator) }, + content = { + Box(Modifier.padding(paddingValues).consumeWindowInsets(paddingValues).statusBarsPadding()) { + // Apply AnimatedContent/CurrentScreen logic here + } + } + ) + } + ``` +* **Removal**: Delete `PillBottomNavigationBar` and `PillNavItem`. + +#### 1.2 Implement `AppNavigationBar` (M3 Expressive) +* **Component**: Use `androidx.compose.material3.NavigationBar`. +* **Styling**: + * Set `alwaysShowLabel = true`. + * Use `LocalNavBarColor.current` for `containerColor`. +* **Items & Icons (Rounded variants)**: + 1. **Libraries**: `Icons.Rounded.LocalLibrary`. Toggles side drawer via `vm.toggleNavBar()`. + 2. **Home**: `Icons.Rounded.Home`. `navigator.replaceAll(HomeScreen())`. Selected if `lastItem is HomeScreen`. + 3. **Search**: `Icons.Rounded.Search`. `navigator.push(SearchScreen(null))`. Selected if `lastItem is SearchScreen`. + 4. **Settings**: `Icons.Rounded.Settings`. **CRITICAL**: Use `navigator.push(MobileSettingsScreen())` (NOT `navigator.parent!!.push`). This keeps the bottom bar visible. Selected if `lastItem is SettingsScreen`. + +#### 1.3 `MobileSettingsScreen.kt` Cleanup +* **Function**: `Content()` +* **Change**: Delete `Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))` and any hardcoded bottom padding in the main `Column`. The `Scaffold`'s `paddingValues` in `MainScreen` will manage this. + +#### 1.4 Padding Cleanup in Lists +* **`SeriesLists.kt`**: In `SeriesLazyCardGrid`, reduce `bottom = navBarBottom + 65.dp` in `contentPadding` to rely on the parent `Scaffold` padding. +* **`HomeContent.kt`**: In `DisplayContent`, remove `contentPadding = PaddingValues(bottom = 50.dp)` from `LazyColumn` and `LazyVerticalGrid`. + +### Changes included: +* **Exclusion**: Immersive screens (`SeriesScreen`, `BookScreen`, `OneshotScreen`) hide the bar. +* **Anchoring**: The bar is anchored to the bottom using `Scaffold`. +* **UI Isolation**: `StandardBottomNavigationBar` remains unchanged for users with "New UI" disabled. + +--- + +## 2. Floating Action Buttons (FABs) - DONE + +### Target +* **File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt` +* **Current State**: Custom split-pill design for "Read Now" and "Incognito". +* **M3 Target**: Separate into standard M3 FAB components (Expressive update). +* **Changes**: + * "Read Now": **`ExtendedFloatingActionButton`** with `Icons.AutoMirrored.Rounded.MenuBook`. + * **Color**: Use `LocalNavBarColor.current ?: MaterialTheme.colorScheme.primaryContainer`. + * **Shape**: Default M3 Squircle (`large`). + * "Incognito": **`FloatingActionButton`** with `Icons.Rounded.VisibilityOff`. + * **Color**: Default M3 FAB color (`PrimaryContainer`). + * **Shape**: Default M3 Squircle (`large`). + * "Download": **`FloatingActionButton`** with `Icons.Rounded.Download`. + * **Color**: Default M3 FAB color (`PrimaryContainer`). + * **Shape**: Default M3 Squircle (`large`). + * Ensure proper spacing and alignment at the bottom of the immersive screen. + +--- + +## 3. Dropdown Menus (Action Menus) - DONE + +### Targets +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt` + +### M3 Target +* Standardize visual hierarchy with **leading icons** and M3 typography. + +### Changes +* **Leading Icons**: Added icons to all items (e.g., Edit, Delete, Mark as Read, Analyze). +* **Typography**: Use `MaterialTheme.typography.labelLarge`. +* **Colors**: Use `MaterialTheme.colorScheme.error` for destructive actions via `MenuItemColors`. +* **Cleanup**: Removed manual hover background overrides in favor of standard M3 states. + +--- + +## 4. Card & Toolbar Triggers - DONE + +### Targets +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesImageCard.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookImageCard.kt` + +### Changes +* Ensure all 3-dot triggers use `IconButton` with `Icons.Rounded.MoreVert`. +* Verify 48dp touch targets for all menu triggers. + +--- + +## 5. Verification Plan - DONE +1. **Navigation**: Ensure the M3 NavigationBar appears only when "New UI" is ON and is hidden on immersive screens. +2. **FABs**: Verify the new FAB layout doesn't overlap content and respects accent colors. +3. **Menus**: Confirm icons are aligned and destructive actions are correctly colored. diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index c839e122..ddb32d66 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.statusBars @@ -22,10 +23,10 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.LocalLibrary -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.LocalLibrary +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.DrawerValue.Closed import androidx.compose.material3.DrawerValue.Open import androidx.compose.material3.HorizontalDivider @@ -36,6 +37,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.core.tween @@ -178,54 +181,30 @@ class MainScreen( ) { val coroutineScope = rememberCoroutineScope() val useNewLibraryUI = LocalUseNewLibraryUI.current + val isImmersiveScreen = navigator.lastItem is SeriesScreen || + navigator.lastItem is BookScreen || + navigator.lastItem is OneshotScreen - if (useNewLibraryUI) { - val rawStatusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - CompositionLocalProvider(LocalRawStatusBarHeight provides rawStatusBarHeight) { - Box(Modifier.fillMaxSize().statusBarsPadding()) { - ModalNavigationDrawer( - drawerState = vm.navBarState, - drawerContent = { LibrariesNavBar(vm, navigator) }, - content = { - AnimatedContent( - targetState = navigator.lastItem, - transitionSpec = { fadeIn(tween(400)) togetherWith fadeOut(tween(250)) }, - label = "nav", - ) { screen -> - CompositionLocalProvider(LocalAnimatedVisibilityScope provides this) { - navigator.saveableState("screen", screen) { - screen.Content() - } - } - } - } - ) - val isImmersiveScreen = navigator.lastItem is SeriesScreen || - navigator.lastItem is BookScreen || - navigator.lastItem is OneshotScreen - Column( - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() - ) { - if (!isImmersiveScreen) { - PillBottomNavigationBar( + val rawStatusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + CompositionLocalProvider(LocalRawStatusBarHeight provides rawStatusBarHeight) { + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + bottomBar = { + if (!isImmersiveScreen) { + if (useNewLibraryUI) { + AppNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } } + ) + } else { + StandardBottomNavigationBar( navigator = navigator, toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + modifier = Modifier ) } - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } - } - } else { - Scaffold( - containerColor = MaterialTheme.colorScheme.surface, - bottomBar = { - StandardBottomNavigationBar( - navigator = navigator, - toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, - modifier = Modifier - ) - }, ) { paddingValues -> val layoutDirection = LocalLayoutDirection.current ModalNavigationDrawer( @@ -242,8 +221,19 @@ class MainScreen( bottom = paddingValues.calculateBottomPadding(), ) .consumeWindowInsets(paddingValues) + .statusBarsPadding() ) { - CurrentScreen() + AnimatedContent( + targetState = navigator.lastItem, + transitionSpec = { fadeIn(tween(400)) togetherWith fadeOut(tween(250)) }, + label = "nav", + ) { screen -> + CompositionLocalProvider(LocalAnimatedVisibilityScope provides this) { + navigator.saveableState("screen", screen) { + screen.Content() + } + } + } } } ) @@ -252,71 +242,42 @@ class MainScreen( } @Composable - private fun PillBottomNavigationBar( + private fun AppNavigationBar( navigator: Navigator, toggleLibrariesDrawer: () -> Unit, ) { - val pillColor = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surfaceVariant - Box( - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp), - contentAlignment = Alignment.Center, + val containerColor = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surface + NavigationBar( + containerColor = containerColor, ) { - Surface( - shape = RoundedCornerShape(50), - color = pillColor, - shadowElevation = 12.dp, - tonalElevation = 4.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - PillNavItem( - icon = Icons.Default.LocalLibrary, - onClick = { toggleLibrariesDrawer() }, - isSelected = false, - ) - PillNavItem( - icon = Icons.Default.Home, - onClick = { navigator.replaceAll(HomeScreen()) }, - isSelected = navigator.lastItem is HomeScreen, - ) - PillNavItem( - icon = Icons.Default.Search, - onClick = { navigator.push(SearchScreen(null)) }, - isSelected = navigator.lastItem is SearchScreen, - ) - PillNavItem( - icon = Icons.Default.Settings, - onClick = { navigator.parent!!.push(MobileSettingsScreen()) }, - isSelected = navigator.lastItem is SettingsScreen, - ) - } - } - } - } - - @Composable - private fun PillNavItem( - icon: ImageVector, - onClick: () -> Unit, - isSelected: Boolean, - ) { - val pillColor = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surfaceVariant - val bgColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent - val iconTint = if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer - else contentColorFor(pillColor) - Box( - modifier = Modifier - .clip(CircleShape) - .background(bgColor) - .clickable { onClick() } - .cursorForHand() - .padding(12.dp), - contentAlignment = Alignment.Center, - ) { - Icon(icon, contentDescription = null, tint = iconTint) + NavigationBarItem( + alwaysShowLabel = true, + selected = false, + onClick = toggleLibrariesDrawer, + icon = { Icon(Icons.Rounded.LocalLibrary, null) }, + label = { Text("Libraries") } + ) + NavigationBarItem( + alwaysShowLabel = true, + selected = navigator.lastItem is HomeScreen, + onClick = { navigator.replaceAll(HomeScreen()) }, + icon = { Icon(Icons.Rounded.Home, null) }, + label = { Text("Home") } + ) + NavigationBarItem( + alwaysShowLabel = true, + selected = navigator.lastItem is SearchScreen, + onClick = { navigator.push(SearchScreen(null)) }, + icon = { Icon(Icons.Rounded.Search, null) }, + label = { Text("Search") } + ) + NavigationBarItem( + alwaysShowLabel = true, + selected = navigator.lastItem is MobileSettingsScreen || navigator.lastItem is SettingsScreen, + onClick = { navigator.push(MobileSettingsScreen()) }, + icon = { Icon(Icons.Rounded.Settings, null) }, + label = { Text("Settings") } + ) } } @@ -337,7 +298,7 @@ class MainScreen( ) { CompactNavButton( text = "Libraries", - icon = Icons.Default.LocalLibrary, + icon = Icons.Rounded.LocalLibrary, onClick = { toggleLibrariesDrawer() }, isSelected = false, modifier = Modifier.weight(1f) @@ -345,7 +306,7 @@ class MainScreen( CompactNavButton( text = "Home", - icon = Icons.Default.Home, + icon = Icons.Rounded.Home, onClick = { navigator.replaceAll(HomeScreen()) }, isSelected = navigator.lastItem is HomeScreen, modifier = Modifier.weight(1f) @@ -353,7 +314,7 @@ class MainScreen( CompactNavButton( text = "Search", - icon = Icons.Default.Search, + icon = Icons.Rounded.Search, onClick = { navigator.push(SearchScreen(null)) }, isSelected = navigator.lastItem is SearchScreen, modifier = Modifier.weight(1f) @@ -361,13 +322,12 @@ class MainScreen( CompactNavButton( text = "Settings", - icon = Icons.Default.Settings, - onClick = { navigator.parent!!.push(MobileSettingsScreen()) }, - isSelected = navigator.lastItem is SettingsScreen, + icon = Icons.Rounded.Settings, + onClick = { navigator.push(MobileSettingsScreen()) }, + isSelected = navigator.lastItem is SettingsScreen || navigator.lastItem is MobileSettingsScreen, modifier = Modifier.weight(1f) ) } - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt index 3016a6da..b59d797f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.OfflinePin +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -459,7 +459,7 @@ private fun BookDetailedListDetails( onClick = { isMenuExpanded = true }, colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) ) { - Icon(Icons.Default.MoreVert, null) + Icon(Icons.Rounded.MoreVert, null) } BookActionsMenu( book = book, @@ -487,7 +487,7 @@ private fun BookMenuActionsDropdown( onClick = { onActionsMenuExpand(true) }, colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface) ) { - Icon(Icons.Default.MoreVert, null) + Icon(Icons.Rounded.MoreVert, null) } BookActionsMenu( @@ -500,4 +500,3 @@ private fun BookMenuActionsDropdown( ) } } - diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt index cb5e778f..cd5ce007 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt @@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -150,7 +150,7 @@ private fun SeriesCardHoverOverlay( onClick = { isActionsMenuExpanded = true }, colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface) ) { - Icon(Icons.Default.MoreVert, contentDescription = null) + Icon(Icons.Rounded.MoreVert, contentDescription = null) } SeriesActionsMenu( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt index c052bc92..db80b557 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt @@ -1,33 +1,26 @@ package snd.komelia.ui.common.immersive -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.MenuBook -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.unit.dp +import snd.komelia.ui.LocalNavBarColor @Composable fun ImmersiveDetailFab( @@ -37,16 +30,8 @@ fun ImmersiveDetailFab( accentColor: Color? = null, showReadActions: Boolean = true, ) { - val pillBackground = accentColor ?: MaterialTheme.colorScheme.primaryContainer - val pillContentColor = remember(pillBackground) { - if (pillBackground.luminance() > 0.35f) Color(0xFF1C1B1F) else Color(0xFFFFFFFF) - } - val fabBackground = accentColor?.let { - if (it.luminance() > 0.5f) it.copy(alpha = 0.75f) else it.copy(alpha = 0.9f) - } ?: MaterialTheme.colorScheme.secondaryContainer - val fabContentColor = remember(fabBackground) { - if (fabBackground.luminance() > 0.35f) Color(0xFF1C1B1F) else Color(0xFFFFFFFF) - } + val navBarColor = LocalNavBarColor.current + val readNowContainerColor = navBarColor ?: MaterialTheme.colorScheme.primaryContainer Row( horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -58,71 +43,36 @@ fun ImmersiveDetailFab( Spacer(modifier = Modifier.weight(1f)) if (showReadActions) { - // Split pill: Read Now | Incognito - Surface( - shape = CircleShape, - color = pillBackground, - modifier = Modifier.height(56.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.height(56.dp) - ) { - // Read Now - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxHeight() - .clickable(onClick = onReadClick) - .padding(horizontal = 20.dp) - ) { - Icon( - Icons.AutoMirrored.Rounded.MenuBook, - contentDescription = "Read Now", - tint = pillContentColor - ) - Text( - text = "Read Now", - style = MaterialTheme.typography.labelLarge, - color = pillContentColor - ) - } - - VerticalDivider( - modifier = Modifier.fillMaxHeight(0.6f), - color = pillContentColor.copy(alpha = 0.3f) + ExtendedFloatingActionButton( + onClick = onReadClick, + containerColor = readNowContainerColor, + contentColor = contentColorFor(readNowContainerColor), + icon = { + Icon( + Icons.AutoMirrored.Rounded.MenuBook, + contentDescription = null ) + }, + text = { Text("Read Now") } + ) - // Incognito - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxHeight() - .clickable(onClick = onReadIncognitoClick) - .padding(horizontal = 16.dp) - ) { - Icon( - Icons.Default.VisibilityOff, - contentDescription = "Read Incognito", - tint = pillContentColor - ) - } - } + FloatingActionButton( + onClick = onReadIncognitoClick, + ) { + Icon( + Icons.Rounded.VisibilityOff, + contentDescription = "Read Incognito" + ) } } // Download FAB FloatingActionButton( onClick = onDownloadClick, - modifier = Modifier.size(56.dp), - shape = CircleShape, - containerColor = fabBackground, ) { Icon( - Icons.Filled.Download, - contentDescription = "Download", - tint = fabContentColor + Icons.Rounded.Download, + contentDescription = "Download" ) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt index fd0518ba..d626413a 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt @@ -82,9 +82,6 @@ fun SeriesLazyCardGrid( val useNewLibraryUI = LocalUseNewLibraryUI.current val cardSpacing = if (useNewLibraryUI) 7.dp else 15.dp val horizontalPadding = if (useNewLibraryUI) 10.dp else 20.dp - val navBarBottom = with(LocalDensity.current) { - WindowInsets.navigationBars.getBottom(this).toDp() - } Box(modifier) { LazyVerticalGrid( state = gridState, @@ -93,7 +90,7 @@ fun SeriesLazyCardGrid( verticalArrangement = Arrangement.spacedBy(cardSpacing), contentPadding = PaddingValues( start = horizontalPadding, end = horizontalPadding, - bottom = navBarBottom + 65.dp, + bottom = 15.dp, ), ) { item(span = { GridItemSpan(maxLineSpan) }) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt index ac48321c..13b0acbf 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt @@ -1,12 +1,20 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Label +import androidx.compose.material.icons.automirrored.rounded.LabelOff +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -116,7 +124,8 @@ fun BookActionsMenu( ) { if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Analyze") }, + text = { Text("Analyze", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { actions.analyze(book) onDismissRequest() @@ -124,7 +133,8 @@ fun BookActionsMenu( ) DropdownMenuItem( - text = { Text("Refresh metadata") }, + text = { Text("Refresh metadata", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { actions.refreshMetadata(book) onDismissRequest() @@ -132,7 +142,8 @@ fun BookActionsMenu( ) DropdownMenuItem( - text = { Text("Add to read list") }, + text = { Text("Add to read list", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Add, null) }, onClick = { showAddToReadListDialog = true }, ) } @@ -142,7 +153,8 @@ fun BookActionsMenu( if (!isRead) { DropdownMenuItem( - text = { Text("Mark as read") }, + text = { Text("Mark as read", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Label, null) }, onClick = { actions.markAsRead(book) onDismissRequest() @@ -152,7 +164,8 @@ fun BookActionsMenu( if (!isUnread) { DropdownMenuItem( - text = { Text("Mark as unread") }, + text = { Text("Mark as unread", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.LabelOff, null) }, onClick = { actions.markAsUnread(book) onDismissRequest() @@ -162,47 +175,43 @@ fun BookActionsMenu( if (isAdmin && !isOffline && showEditOption) { DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showEditDialog = true }, ) } if (!isOffline && showDownloadOption) { DropdownMenuItem( - text = { Text("Download") }, + text = { Text("Download", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Download, null) }, onClick = { showDownloadDialog = true }, ) } if (book.downloaded) { - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() - val deleteColor = - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier DropdownMenuItem( - text = { Text("Delete downloaded") }, + text = { Text("Delete downloaded", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null) }, onClick = { showDeleteDownloadedDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then(deleteColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } -// if (isAdmin && !isOffline) { -// val deleteInteractionSource = remember { MutableInteractionSource() } -// val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() -// val deleteColor = -// if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) -// else Modifier -// DropdownMenuItem( -// text = { Text("Delete from server") }, -// onClick = { showDeleteDialog = true }, -// modifier = Modifier -// .hoverable(deleteInteractionSource) -// .then(deleteColor) -// ) -// } + if (isAdmin && !isOffline) { + DropdownMenuItem( + text = { Text("Delete from server", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, + onClick = { showDeleteDialog = true }, + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) + ) + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt index 922d1556..78347a9f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt @@ -1,12 +1,13 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -14,7 +15,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import snd.komelia.ui.dialogs.ConfirmationDialog import snd.komelia.ui.dialogs.collectionedit.CollectionEditDialog import snd.komga.client.collection.KomgaCollection @@ -58,22 +58,20 @@ fun CollectionActionsMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showEditDialog = true }, ) DropdownMenuItem( - text = { Text("Delete") }, + text = { Text("Delete", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, onClick = { showDeleteDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then( - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier - ) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt index 98dc3e2d..286febd6 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt @@ -1,9 +1,9 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.DeleteSweep import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme @@ -29,6 +29,12 @@ import snd.komelia.ui.dialogs.ConfirmationDialog import snd.komelia.ui.dialogs.komf.reset.KomfResetLibraryMetadataDialog import snd.komelia.ui.dialogs.libraryedit.LibraryEditDialogs import snd.komga.client.library.KomgaLibrary +import androidx.compose.material3.Icon +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.automirrored.rounded.ManageSearch +import androidx.compose.material3.MenuDefaults @Composable fun LibraryActionsMenu( @@ -111,52 +117,49 @@ fun LibraryActionsMenu( DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Scan library files") }, + text = { Text("Scan library files", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { actions.scan(library) onDismissRequest() } ) - val deepScanInteractionSource = remember { MutableInteractionSource() } - val deepScanIsHovered = deepScanInteractionSource.collectIsHoveredAsState() - val deepScanColor = - if (deepScanIsHovered.value) Modifier.background(MaterialTheme.colorScheme.tertiaryContainer) - else Modifier - DropdownMenuItem( - text = { Text("Scan library files (deep)") }, + text = { Text("Scan library files (deep)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.ManageSearch, null) }, onClick = { actions.deepScan(library) onDismissRequest() - }, - modifier = Modifier - .hoverable(deepScanInteractionSource) - .then(deepScanColor) + } ) DropdownMenuItem( - text = { Text("Analyze") }, + text = { Text("Analyze", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { showAnalyzeDialog = true onDismissRequest() } ) DropdownMenuItem( - text = { Text("Refresh metadata") }, + text = { Text("Refresh metadata", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { refreshMetadataDialog = true onDismissRequest() } ) DropdownMenuItem( - text = { Text("Empty trash") }, + text = { Text("Empty trash", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteSweep, null) }, onClick = { emptyTrashDialog = true onDismissRequest() } ) DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showLibraryEditDialog = true onDismissRequest() @@ -171,7 +174,8 @@ fun LibraryActionsMenu( vmFactory.getKomfLibraryIdentifyViewModel(library) } DropdownMenuItem( - text = { Text("Auto-Identify (Komf)") }, + text = { Text("Auto-Identify (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { autoIdentifyVm.autoIdentify() onDismissRequest() @@ -179,39 +183,38 @@ fun LibraryActionsMenu( ) DropdownMenuItem( - text = { Text("Reset Metadata (Komf)") }, + text = { Text("Reset Metadata (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { showKomfResetDialog = true }, ) } - val deleteScanInteractionSource = remember { MutableInteractionSource() } - val deleteScanIsHovered = deleteScanInteractionSource.collectIsHoveredAsState() - val deleteScanColor = - if (deleteScanIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier - if (!isOffline && isAdmin) { DropdownMenuItem( - text = { Text("Delete") }, + text = { Text("Delete", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, onClick = { deleteLibraryDialog = true onDismissRequest() }, - modifier = Modifier - .hoverable(deleteScanInteractionSource) - .then(deleteScanColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } if (isOffline) { DropdownMenuItem( - text = { Text("Delete downloaded") }, + text = { Text("Delete downloaded", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null) }, onClick = { deleteOfflineLibraryDialog = true onDismissRequest() }, - modifier = Modifier - .hoverable(deleteScanInteractionSource) - .then(deleteScanColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt index d18c8203..8cc7f264 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt @@ -1,12 +1,18 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Label +import androidx.compose.material.icons.rounded.LabelOff +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -126,7 +132,8 @@ fun OneshotActionsMenu( ) { if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Analyze") }, + text = { Text("Analyze", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { actions.analyze(book) onDismissRequest() @@ -134,7 +141,8 @@ fun OneshotActionsMenu( ) DropdownMenuItem( - text = { Text("Refresh metadata") }, + text = { Text("Refresh metadata", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { actions.refreshMetadata(book) onDismissRequest() @@ -142,11 +150,13 @@ fun OneshotActionsMenu( ) DropdownMenuItem( - text = { Text("Add to read list") }, + text = { Text("Add to read list", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Add, null) }, onClick = { showAddToReadListDialog = true }, ) DropdownMenuItem( - text = { Text("Add to collection") }, + text = { Text("Add to collection", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Add, null) }, onClick = { showAddToCollectionDialog = true }, ) } @@ -156,7 +166,8 @@ fun OneshotActionsMenu( if (!isRead) { DropdownMenuItem( - text = { Text("Mark as read") }, + text = { Text("Mark as read", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Label, null) }, onClick = { actions.markAsRead(book) onDismissRequest() @@ -166,7 +177,8 @@ fun OneshotActionsMenu( if (!isUnread) { DropdownMenuItem( - text = { Text("Mark as unread") }, + text = { Text("Mark as unread", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.LabelOff, null) }, onClick = { actions.markAsUnread(book) onDismissRequest() @@ -177,40 +189,41 @@ fun OneshotActionsMenu( val komfIntegration = LocalKomfIntegration.current.collectAsState(false) if (komfIntegration.value) { DropdownMenuItem( - text = { Text("Identify (Komf)") }, + text = { Text("Identify (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { showKomfDialog = true }, ) DropdownMenuItem( - text = { Text("Reset Metadata (Komf)") }, + text = { Text("Reset Metadata (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { showKomfResetDialog = true }, ) } - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() - val deleteColor = - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Delete") }, + text = { Text("Delete", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, onClick = { showDeleteDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then(deleteColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } if (isOffline) { DropdownMenuItem( - text = { Text("Delete downloaded") }, + text = { Text("Delete downloaded", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null) }, onClick = { showDeleteDownloadedDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then(deleteColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt index 6a345f2c..37cfa09c 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt @@ -1,12 +1,13 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -14,7 +15,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import snd.komelia.ui.dialogs.ConfirmationDialog import snd.komelia.ui.dialogs.readlistedit.ReadListEditDialog import snd.komga.client.readlist.KomgaReadList @@ -57,23 +57,20 @@ fun ReadListActionsMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() - DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showEditDialog = true }, ) DropdownMenuItem( - text = { Text("Delete") }, + text = { Text("Delete", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, onClick = { showDeleteDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then( - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier - ) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt index 66f60ec5..8dfd241a 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt @@ -1,12 +1,20 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Label +import androidx.compose.material.icons.automirrored.rounded.LabelOff +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -147,7 +155,8 @@ fun SeriesActionsMenu( ) { if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Analyze") }, + text = { Text("Analyze", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { actions.analyze(series) onDismissRequest() @@ -155,7 +164,8 @@ fun SeriesActionsMenu( ) DropdownMenuItem( - text = { Text("Refresh metadata") }, + text = { Text("Refresh metadata", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { actions.refreshMetadata(series) onDismissRequest() @@ -163,7 +173,8 @@ fun SeriesActionsMenu( ) DropdownMenuItem( - text = { Text("Add to collection") }, + text = { Text("Add to collection", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Add, null) }, onClick = { showAddToCollectionDialog = true }, ) } @@ -172,7 +183,8 @@ fun SeriesActionsMenu( val isUnread = remember { series.booksUnreadCount == series.booksCount } if (!isRead) { DropdownMenuItem( - text = { Text("Mark as read") }, + text = { Text("Mark as read", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Label, null) }, onClick = { actions.markAsRead(series) onDismissRequest() @@ -182,7 +194,8 @@ fun SeriesActionsMenu( if (!isUnread) { DropdownMenuItem( - text = { Text("Mark as unread") }, + text = { Text("Mark as unread", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.LabelOff, null) }, onClick = { actions.markAsUnread(series) onDismissRequest() @@ -192,30 +205,29 @@ fun SeriesActionsMenu( if (isAdmin && !isOffline && showEditOption) { DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showEditDialog = true }, ) } if (!isOffline && showDownloadOption) { DropdownMenuItem( - text = { Text("Download") }, + text = { Text("Download", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Download, null) }, onClick = { showDownloadDialog = true }, ) } if (isOffline) { - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() - val deleteColor = - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier DropdownMenuItem( - text = { Text("Delete downloaded") }, + text = { Text("Delete downloaded", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null) }, onClick = { showDeleteDownloadedDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then(deleteColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } @@ -223,30 +235,29 @@ fun SeriesActionsMenu( val komfIntegration = LocalKomfIntegration.current.collectAsState(false) if (komfIntegration.value) { DropdownMenuItem( - text = { Text("Identify (Komf)") }, + text = { Text("Identify (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { showKomfDialog = true }, ) DropdownMenuItem( - text = { Text("Reset Metadata (Komf)") }, + text = { Text("Reset Metadata (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { showKomfResetDialog = true }, ) } -// if (isAdmin && !isOffline) { -// val deleteInteractionSource = remember { MutableInteractionSource() } -// val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() -// val deleteColor = -// if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) -// else Modifier -// DropdownMenuItem( -// text = { Text("Delete from server") }, -// onClick = { showDeleteDialog = true }, -// modifier = Modifier -// .hoverable(deleteInteractionSource) -// .then(deleteColor) -// ) -// } + if (isAdmin && !isOffline) { + DropdownMenuItem( + text = { Text("Delete from server", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, + onClick = { showDeleteDialog = true }, + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) + ) + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt index ae0f4ba3..9950283b 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt @@ -222,7 +222,7 @@ private fun DisplayContent( LazyColumn( state = columnState, verticalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues(bottom = 50.dp), + contentPadding = PaddingValues(bottom = 15.dp), ) { for (data in filters) { val isEmpty = when (data) { @@ -254,7 +254,7 @@ private fun DisplayContent( columns = GridCells.Adaptive(cardWidth), horizontalArrangement = Arrangement.spacedBy(15.dp), verticalArrangement = Arrangement.spacedBy(15.dp), - contentPadding = PaddingValues(bottom = 50.dp) + contentPadding = PaddingValues(bottom = 15.dp) ) { for (data in filters) { if (activeFilterNumber == 0 || data.filter.order == activeFilterNumber) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt index b2e9c22f..27d8fb91 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt @@ -71,8 +71,6 @@ class MobileSettingsScreen : Screen { contentColor = MaterialTheme.colorScheme.surface, modifier = Modifier.weight(1f, false) ) - - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } BackPressHandler { currentNavigator.pop() } From 36672b833b105b8e8e1c56d94e7b700981a3470e Mon Sep 17 00:00:00 2001 From: eserero Date: Sat, 28 Feb 2026 20:23:16 +0200 Subject: [PATCH 19/35] style(ui): update chips to Material 3 squarish design and fix navigation crash - Update 'AppFilterChipDefaults' and 'NoPaddingChip' to use Material 3 squarish shape (8dp). - Lighten unselected chip borders using 'outline' color and 'Dp.Hairline'. - Add 'AppSuggestionChipDefaults' and apply to count chips in Library and Series screens. - Fix IllegalArgumentException on Android by adding navigation guards to prevent redundant screen pushes. --- IMMERSIVE_TRANSITION_ANALYSIS.md | 46 +++++++++++++++++++ .../kotlin/snd/komelia/ui/MainScreen.kt | 18 +++++--- .../ui/common/components/DescriptionChips.kt | 19 +++++--- .../library/view/LibraryCollectionsContent.kt | 2 + .../library/view/LibraryReadListsContent.kt | 2 + .../ui/series/list/SeriesListContent.kt | 2 + 6 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 IMMERSIVE_TRANSITION_ANALYSIS.md diff --git a/IMMERSIVE_TRANSITION_ANALYSIS.md b/IMMERSIVE_TRANSITION_ANALYSIS.md new file mode 100644 index 00000000..7cb8b609 --- /dev/null +++ b/IMMERSIVE_TRANSITION_ANALYSIS.md @@ -0,0 +1,46 @@ +# Analysis: Immersive Detail Screen Smoothness and Flickering + +## 1. Problem Description +Users experience visual instability when navigating to a book or oneshot page in immersive mode from a series page. The issues include: +* **Visual "Jumps"**: The content card moves inconsistently during the opening animation. +* **Layering Issues**: The cover image appears above the content card briefly before the card "pops" to the front. +* **Image Flickering**: The image seems to load once, then "flashes" or reloads shortly after the screen opens. + +These issues do not occur when opening the immersive series screen from the Library or Home screens. + +--- + +## 2. 1-to-1 Comparison: Why Series Works and Books Don't + +| Feature | Series Transition (Working) | Book Transition (Buggy) | +| :--- | :--- | :--- | +| **Layout Level** | Root of the screen. | Nested inside a **HorizontalPager**. | +| **Constraints** | Uses stable screen height. | Recalculates height every frame due to morphing. | +| **Data Flow** | Data is static for the screen. | **Asynchronous**: Pager re-layouts after opening. | +| **Crossfade** | Managed by `SeriesThumbnail`. | **Hardcoded to `true`** in the scaffold. | + +--- + +## 3. Implementation Plan: Replicating Series Smoothness + +To make the book transition behave identically to the working series transition, the following changes are required: + +### I. Stabilize Layout Constraints (Fixes the "Jump") +The current scaffold applies `sharedBounds` to a `BoxWithConstraints`. As the shared transition morphs the thumbnail into the full screen, the `maxHeight` changes every frame, causing the card's position (calculated as `0.65 * maxHeight`) to jump. +* **Fix**: Move the `sharedBounds` modifier from the `BoxWithConstraints` to the inner `Box`. This ensures the layout logic always sees the full stable screen height, while the contents still morph visually. + +### II. Synchronize Pager State (Fixes the "Flicker") +The series screen is smooth because it is static. The book screen flickers because the `HorizontalPager` starts with 1 item and then "jumps" or reloads once the full list of books arrives from the server. +* **Fix**: Update `SeriesScreen` to pass the currently loaded list of books to the `BookScreen` during navigation. This allows the pager to initialize with the correct page count and index from frame one, making it as stable as the series screen. + +### III. Explicit Layering (Fixes the Layering Issue) +When using shared transitions, elements are rendered in a top-level overlay. Standard composition order can be ignored in this mode. +* **Fix**: Apply explicit `.zIndex(0f)` to the background image and `.zIndex(1f)` to the content card in `ImmersiveDetailScaffold`. This forces the card to remain on top of the image throughout the entire animation. + +### IV. Disable Redundant Animations (Fixes the Image Flash) +During a shared transition, the image is already being visually morphed from the source thumbnail. If the destination image also performs its own `crossfade`, it creates a visual "double load" flash. +* **Fix**: Update the scaffold to disable the `ThumbnailImage` crossfade whenever a shared transition is active, matching the logic already used in the working series thumbnail. + +### V. Transition Key Alignment (Fixes Oneshots) +Navigating to a oneshot from a book list currently fails to trigger a shared transition because of an ID mismatch (Source uses `bookId`, Destination uses `seriesId`). +* **Fix**: Update `ImmersiveOneshotContent` to use the `bookId` for its transition key when navigated to from a book list context. diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index ddb32d66..c333af46 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -260,21 +260,24 @@ class MainScreen( NavigationBarItem( alwaysShowLabel = true, selected = navigator.lastItem is HomeScreen, - onClick = { navigator.replaceAll(HomeScreen()) }, + onClick = { if (navigator.lastItem !is HomeScreen) navigator.replaceAll(HomeScreen()) }, icon = { Icon(Icons.Rounded.Home, null) }, label = { Text("Home") } ) NavigationBarItem( alwaysShowLabel = true, selected = navigator.lastItem is SearchScreen, - onClick = { navigator.push(SearchScreen(null)) }, + onClick = { if (navigator.lastItem !is SearchScreen) navigator.push(SearchScreen(null)) }, icon = { Icon(Icons.Rounded.Search, null) }, label = { Text("Search") } ) NavigationBarItem( alwaysShowLabel = true, selected = navigator.lastItem is MobileSettingsScreen || navigator.lastItem is SettingsScreen, - onClick = { navigator.push(MobileSettingsScreen()) }, + onClick = { + if (navigator.lastItem !is MobileSettingsScreen && navigator.lastItem !is SettingsScreen) + navigator.push(MobileSettingsScreen()) + }, icon = { Icon(Icons.Rounded.Settings, null) }, label = { Text("Settings") } ) @@ -307,7 +310,7 @@ class MainScreen( CompactNavButton( text = "Home", icon = Icons.Rounded.Home, - onClick = { navigator.replaceAll(HomeScreen()) }, + onClick = { if (navigator.lastItem !is HomeScreen) navigator.replaceAll(HomeScreen()) }, isSelected = navigator.lastItem is HomeScreen, modifier = Modifier.weight(1f) ) @@ -315,7 +318,7 @@ class MainScreen( CompactNavButton( text = "Search", icon = Icons.Rounded.Search, - onClick = { navigator.push(SearchScreen(null)) }, + onClick = { if (navigator.lastItem !is SearchScreen) navigator.push(SearchScreen(null)) }, isSelected = navigator.lastItem is SearchScreen, modifier = Modifier.weight(1f) ) @@ -323,7 +326,10 @@ class MainScreen( CompactNavButton( text = "Settings", icon = Icons.Rounded.Settings, - onClick = { navigator.push(MobileSettingsScreen()) }, + onClick = { + if (navigator.lastItem !is MobileSettingsScreen && navigator.lastItem !is SettingsScreen) + navigator.push(MobileSettingsScreen()) + }, isSelected = navigator.lastItem is SettingsScreen || navigator.lastItem is MobileSettingsScreen, modifier = Modifier.weight(1f) ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt index f5e31c49..3282423e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt @@ -92,7 +92,7 @@ fun DescriptionChips( @Composable fun NoPaddingChip( - borderColor: Color = MaterialTheme.colorScheme.surfaceVariant, + borderColor: Color = MaterialTheme.colorScheme.outline, color: Color = Color.Unspecified, onClick: () -> Unit = {}, modifier: Modifier = Modifier, @@ -100,8 +100,8 @@ fun NoPaddingChip( ) { Box( modifier = modifier - .border(Dp.Hairline, borderColor, RoundedCornerShape(10.dp)) - .clip(RoundedCornerShape(10.dp)) + .border(Dp.Hairline, borderColor, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) .background(color) .clickable { onClick() } .padding(10.dp, 5.dp) @@ -122,8 +122,7 @@ object AppFilterChipDefaults { @Composable fun shape(): Shape { - return if (LocalUseNewLibraryUI.current) RoundedCornerShape(percent = 50) - else FilterChipDefaults.shape + return FilterChipDefaults.shape } @Composable @@ -141,7 +140,13 @@ object AppFilterChipDefaults { @Composable fun filterChipBorder(selected: Boolean): BorderStroke? { - val accent = LocalAccentColor.current ?: MaterialTheme.colorScheme.primary - return if (selected) null else BorderStroke(1.dp, accent) + return if (selected) null else BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.outline) + } +} + +object AppSuggestionChipDefaults { + @Composable + fun shape(): Shape { + return androidx.compose.material3.SuggestionChipDefaults.shape } } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryCollectionsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryCollectionsContent.kt index c0d2d036..0fb24370 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryCollectionsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryCollectionsContent.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import snd.komelia.ui.common.components.AppSuggestionChipDefaults import snd.komelia.ui.common.components.LoadingMaxSizeIndicator import snd.komelia.ui.common.components.PageSizeSelectionDropdown import snd.komelia.ui.common.itemlist.CollectionLazyCardGrid @@ -47,6 +48,7 @@ fun LibraryCollectionsContent( if (collectionsTotalCount > 1) Text("$collectionsTotalCount collections") else Text("$collectionsTotalCount collection") }, + shape = AppSuggestionChipDefaults.shape(), modifier = Modifier.padding(end = 10.dp) ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryReadListsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryReadListsContent.kt index cb01e1db..1457669e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryReadListsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryReadListsContent.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import snd.komelia.ui.common.components.AppSuggestionChipDefaults import snd.komelia.ui.common.components.LoadingMaxSizeIndicator import snd.komelia.ui.common.components.PageSizeSelectionDropdown import snd.komelia.ui.common.itemlist.PlaceHolderLazyCardGrid @@ -48,6 +49,7 @@ fun LibraryReadListsContent( if (readListsTotalCount > 1) Text("$readListsTotalCount read lists") else Text("$readListsTotalCount read list") }, + shape = AppSuggestionChipDefaults.shape(), modifier = Modifier.padding(end = 10.dp) ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt index 6d030f5e..cac19d0c 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt @@ -39,6 +39,7 @@ import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.LocalWindowWidth import snd.komelia.ui.common.cards.BookImageCard +import snd.komelia.ui.common.components.AppSuggestionChipDefaults import snd.komelia.ui.common.components.PageSizeSelectionDropdown import snd.komelia.ui.common.itemlist.SeriesLazyCardGrid import snd.komelia.ui.common.menus.BookMenuActions @@ -229,6 +230,7 @@ private fun ToolBar( SuggestionChip( onClick = {}, label = { Text("$seriesTotalCount series") }, + shape = AppSuggestionChipDefaults.shape(), ) Spacer(Modifier.weight(1f)) From 38de3e00042da0e0e922d07fb20643946903997f Mon Sep 17 00:00:00 2001 From: eserero Date: Sat, 28 Feb 2026 21:29:15 +0200 Subject: [PATCH 20/35] feat(ui): refine Library screen toolbar layout - Move library options menu to the right. - Make library name clickable to toggle the navigation drawer. - Truncate long library names with ellipsis (max width 150dp). - Optimize spacing between name and chips. - Add end padding to chips row to prevent overlap with menu button. --- .../snd/komelia/ui/CompositionLocals.kt | 1 + .../kotlin/snd/komelia/ui/MainScreen.kt | 8 +- .../immersive/ImmersiveDetailScaffold.kt | 143 ++++++++++-------- .../snd/komelia/ui/library/LibraryScreen.kt | 135 ++++++++++------- 4 files changed, 162 insertions(+), 125 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt index 3d50e80c..a58d27f3 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt @@ -23,6 +23,7 @@ import snd.komga.client.library.KomgaLibrary import snd.komga.client.sse.KomgaEvent val LocalViewModelFactory = compositionLocalOf { error("ViewModel factory is not set") } +val LocalMainScreenViewModel = compositionLocalOf { error("MainScreenViewModel is not set") } val LocalToaster = compositionLocalOf { error("Toaster is not set") } val LocalKomgaEvents = compositionLocalOf> { error("Komga events are not set") } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index c333af46..7f8f3108 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -104,9 +104,11 @@ class MainScreen( ) { navigator -> val vm = rememberScreenModel { viewModelFactory.getNavigationViewModel() } - when (platform) { - MOBILE -> MobileLayout(navigator, vm) - DESKTOP, WEB_KOMF -> DesktopLayout(navigator, vm) + CompositionLocalProvider(LocalMainScreenViewModel provides vm) { + when (platform) { + MOBILE -> MobileLayout(navigator, vm) + DESKTOP, WEB_KOMF -> DesktopLayout(navigator, vm) + } } LaunchedEffect(Unit) { vm.initialize(navigator) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index 3ef49bb0..39b2bbe0 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -116,6 +116,11 @@ fun ImmersiveDetailScaffold( // reads inside it happen too late for SharedTransitionLayout's composition-phase matching. val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + // The entire scaffold is the shared element — it morphs from the source thumbnail's bounds + // to full-screen. Applied to an inner Box (not BoxWithConstraints) so the outer + // BoxWithConstraints always measures at real screen dimensions, keeping collapsedOffset + // correct throughout the animation. val scaffoldSharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { with(sharedTransitionScope) { Modifier.sharedBounds( @@ -128,6 +133,9 @@ fun ImmersiveDetailScaffold( } } else Modifier + // Whether a shared transition is in progress — used to suppress crossfade during animation. + val inSharedTransition = sharedTransitionScope != null && animatedVisibilityScope != null + val uiEnterExitModifier = if (animatedVisibilityScope != null) { with(animatedVisibilityScope) { Modifier.animateEnterExit( @@ -151,9 +159,10 @@ fun ImmersiveDetailScaffold( } } else Modifier - Box(modifier = modifier.fillMaxSize()) { - - BoxWithConstraints(modifier = Modifier.fillMaxSize().then(scaffoldSharedModifier)) { + // Outer BoxWithConstraints is NOT under sharedBounds, so maxHeight always equals the real + // screen height — never the thumbnail size mid-morph. All layout values (collapsedOffset, + // card height, cover height) are derived from this real measurement. + BoxWithConstraints(modifier = modifier.fillMaxSize()) { val screenHeight = maxHeight val collapsedOffset = screenHeight * 0.65f val collapsedOffsetPx = with(density) { collapsedOffset.toPx() } @@ -253,76 +262,80 @@ fun ImmersiveDetailScaffold( val statusBarDp = LocalRawStatusBarHeight.current val statusBarPx = with(density) { statusBarDp.toPx() } - Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + // Inner Box carries sharedBounds — it morphs from thumbnail size to full-screen. + // Because layout values come from the outer BoxWithConstraints, the cover image and card + // are always sized/positioned for the full screen, with the morphing clip revealing them + // naturally as the bounds expand. Image and card z-order never flip. + Box(modifier = Modifier.fillMaxSize().then(scaffoldSharedModifier)) { + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { - // Layer 1: Cover image — fades out as card expands - // Extends by the card corner radius so it fills behind the rounded corners - // When immersive=true, shifts up behind the status bar - ThumbnailImage( - data = coverData, - cacheKey = coverKey, - crossfade = true, - contentScale = ContentScale.Crop, - modifier = if (immersive) - Modifier - .fillMaxWidth() - .offset { IntOffset(0, -statusBarPx.roundToInt()) } - .height(collapsedOffset + topCornerRadiusDp + statusBarDp) - .graphicsLayer { alpha = 1f - expandFraction } - else - Modifier - .fillMaxWidth() - .height(collapsedOffset + topCornerRadiusDp) - .graphicsLayer { alpha = 1f - expandFraction } - ) + // Layer 1: Cover image — fades out as card expands + // Extends by the card corner radius so it fills behind the rounded corners + // When immersive=true, shifts up behind the status bar + ThumbnailImage( + data = coverData, + cacheKey = coverKey, + crossfade = !inSharedTransition, + contentScale = ContentScale.Crop, + modifier = if (immersive) + Modifier + .fillMaxWidth() + .offset { IntOffset(0, -statusBarPx.roundToInt()) } + .height(collapsedOffset + topCornerRadiusDp + statusBarDp) + .graphicsLayer { alpha = 1f - expandFraction } + else + Modifier + .fillMaxWidth() + .height(collapsedOffset + topCornerRadiusDp) + .graphicsLayer { alpha = 1f - expandFraction } + ) - // Layer 2: Card - val cardShape = RoundedCornerShape(topStart = topCornerRadiusDp, topEnd = topCornerRadiusDp) - Column( - modifier = Modifier - .offset { IntOffset(0, cardOffsetPx.roundToInt()) } - .fillMaxWidth() - .height(screenHeight) - .nestedScroll(nestedScrollConnection) - .anchoredDraggable(state, Orientation.Vertical) - .shadow(elevation = 6.dp, shape = cardShape) - .clip(cardShape) - .background(backgroundColor) - ) { - Box( - modifier = Modifier.fillMaxWidth().height(28.dp), - contentAlignment = Alignment.Center + // Layer 2: Card + val cardShape = RoundedCornerShape(topStart = topCornerRadiusDp, topEnd = topCornerRadiusDp) + Column( + modifier = Modifier + .offset { IntOffset(0, cardOffsetPx.roundToInt()) } + .fillMaxWidth() + .height(screenHeight) + .nestedScroll(nestedScrollConnection) + .anchoredDraggable(state, Orientation.Vertical) + .shadow(elevation = 6.dp, shape = cardShape) + .clip(cardShape) + .background(backgroundColor) ) { Box( - modifier = Modifier - .size(width = 32.dp, height = 4.dp) - .clip(RoundedCornerShape(2.dp)) - .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) - ) - } - Column(modifier = Modifier.fillMaxWidth().weight(1f)) { - cardContent(expandFraction) + modifier = Modifier.fillMaxWidth().height(28.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(width = 32.dp, height = 4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + ) + } + Column(modifier = Modifier.fillMaxWidth().weight(1f)) { + cardContent(expandFraction) + } } - } - // Layer 5: Top bar - Box(modifier = Modifier.fillMaxWidth().then(uiEnterExitModifier).statusBarsPadding()) { - topBarContent() + // Layer 5: Top bar + Box(modifier = Modifier.fillMaxWidth().then(uiEnterExitModifier).statusBarsPadding()) { + topBarContent() + } } } - } - // Layer 4: FAB — outside shared bounds so it is never clipped by the morphing container - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .then(fabOverlayModifier) - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(bottom = 16.dp) - ) { - fabContent() + // Layer 4: FAB — outside shared bounds so it is never clipped by the morphing container + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .then(fabOverlayModifier) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) + ) { + fabContent() + } } - - } // outer Box } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt index 7ef7c9dc..39248b0c 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt @@ -1,10 +1,16 @@ package snd.komelia.ui.library +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert @@ -19,19 +25,23 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import kotlinx.coroutines.launch import snd.komelia.ui.LoadState.Error import snd.komelia.ui.LoadState.Loading import snd.komelia.ui.LoadState.Success import snd.komelia.ui.LoadState.Uninitialized import snd.komelia.ui.LocalKomgaState +import snd.komelia.ui.LocalMainScreenViewModel import snd.komelia.ui.LocalOfflineMode import snd.komelia.ui.LocalReloadEvents import snd.komelia.ui.LocalViewModelFactory @@ -252,73 +262,84 @@ fun LibraryToolBar( var showOptionsMenu by remember { mutableStateOf(false) } val isAdmin = LocalKomgaState.current.authenticatedUser.collectAsState().value?.roleAdmin() ?: true val isOffline = LocalOfflineMode.current.collectAsState().value + val mainScreenVm = LocalMainScreenViewModel.current + val coroutineScope = rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxWidth()) { + LazyRow( + modifier = Modifier.align(Alignment.CenterStart).padding(end = 48.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item { + Spacer(Modifier.width(15.dp)) + } + item { + Text( + library?.let { library.name } ?: "All Libraries", + modifier = Modifier.widthIn(max = 150.dp).clickable { coroutineScope.launch { mainScreenVm.toggleNavBar() } }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - item { - if (library != null && (isAdmin || isOffline)) { - Box { - IconButton( - onClick = { showOptionsMenu = true } - ) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = null, - ) - } - LibraryActionsMenu( - library = library, - actions = libraryActions, - expanded = showOptionsMenu, - onDismissRequest = { showOptionsMenu = false } + if (collectionsCount > 0 || readListsCount > 0) + item { + FilterChip( + onClick = onBrowseClick, + selected = currentTab == SERIES, + label = { Text("Series") }, + colors = chipColors, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == SERIES), ) } - } - Text(library?.let { library.name } ?: "All Libraries") - - Spacer(Modifier.width(5.dp)) - } + if (collectionsCount > 0) + item { + FilterChip( + onClick = onCollectionsClick, + selected = currentTab == COLLECTIONS, + label = { Text("Collections") }, + colors = chipColors, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == COLLECTIONS), + ) + } - if (collectionsCount > 0 || readListsCount > 0) - item { - FilterChip( - onClick = onBrowseClick, - selected = currentTab == SERIES, - label = { Text("Series") }, - colors = chipColors, - shape = AppFilterChipDefaults.shape(), - border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == SERIES), - ) - } + if (readListsCount > 0) + item { + FilterChip( + onClick = onReadListsClick, + selected = currentTab == READ_LISTS, + label = { Text("Read Lists") }, + colors = chipColors, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == READ_LISTS), + ) + } + } - if (collectionsCount > 0) - item { - FilterChip( - onClick = onCollectionsClick, - selected = currentTab == COLLECTIONS, - label = { Text("Collections") }, - colors = chipColors, - shape = AppFilterChipDefaults.shape(), - border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == COLLECTIONS), - ) - } + if (library != null && (isAdmin || isOffline)) { + Box(modifier = Modifier.align(Alignment.CenterEnd)) { + IconButton( + onClick = { showOptionsMenu = true } + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null, + ) + } - if (readListsCount > 0) - item { - FilterChip( - onClick = onReadListsClick, - selected = currentTab == READ_LISTS, - label = { Text("Read Lists") }, - colors = chipColors, - shape = AppFilterChipDefaults.shape(), - border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == READ_LISTS), + LibraryActionsMenu( + library = library, + actions = libraryActions, + expanded = showOptionsMenu, + onDismissRequest = { showOptionsMenu = false } ) } - + } } } From eb5f007c5271c76b89f3d5ea94665f6c2344c430 Mon Sep 17 00:00:00 2001 From: eserero Date: Sat, 28 Feb 2026 22:15:14 +0200 Subject: [PATCH 21/35] style(ui): improve Home and Library toolbars - Relocate Home screen edit button to top right as a menu icon. - Align Home screen chips with section headers. - Fix Library screen toolbar layout with fixed name and right-aligned menu. --- .../kotlin/snd/komelia/ui/home/HomeContent.kt | 25 ++++++--------- .../snd/komelia/ui/library/LibraryScreen.kt | 32 +++++++++---------- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt index 9950283b..bf05bdae 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt @@ -26,7 +26,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -123,24 +123,12 @@ private fun Toolbar( LazyRow( state = lazyRowState, - modifier = Modifier.animateContentSize(), + modifier = Modifier.animateContentSize().padding(end = 48.dp), horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically, ) { item { - Spacer(Modifier.width(20.dp)) - } - - item { - FilterChip( - onClick = onEditStart, - selected = false, - label = { - Icon(Icons.Default.Tune, null) - }, - colors = chipColors, - border = null, - ) + Spacer(Modifier.width(5.dp)) } if (filters.size > 1) { @@ -180,6 +168,13 @@ private fun Toolbar( } } + IconButton( + onClick = onEditStart, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Icon(Icons.Rounded.MoreVert, null) + } + if (LocalPlatform.current != PlatformType.MOBILE) { Row { if (lazyRowState.canScrollBackward) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt index 39248b0c..3f3caabe 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt @@ -265,25 +265,23 @@ fun LibraryToolBar( val mainScreenVm = LocalMainScreenViewModel.current val coroutineScope = rememberCoroutineScope() - Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(15.dp)) + Text( + library?.let { library.name } ?: "All Libraries", + modifier = Modifier.width(68.dp).clickable { coroutineScope.launch { mainScreenVm.toggleNavBar() } }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + LazyRow( - modifier = Modifier.align(Alignment.CenterStart).padding(end = 48.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - item { - Spacer(Modifier.width(15.dp)) - } - item { - Text( - library?.let { library.name } ?: "All Libraries", - modifier = Modifier.widthIn(max = 150.dp).clickable { coroutineScope.launch { mainScreenVm.toggleNavBar() } }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - if (collectionsCount > 0 || readListsCount > 0) item { FilterChip( @@ -322,7 +320,7 @@ fun LibraryToolBar( } if (library != null && (isAdmin || isOffline)) { - Box(modifier = Modifier.align(Alignment.CenterEnd)) { + Box { IconButton( onClick = { showOptionsMenu = true } ) { From ea7453aa71621795176fa717a93450aaa1c8755d Mon Sep 17 00:00:00 2001 From: eserero Date: Sun, 1 Mar 2026 14:42:18 +0200 Subject: [PATCH 22/35] fix(ui): correct panel reader centering and rotation transitions --- .../image/panels/PanelsReaderContent.kt | 5 +- .../reader/image/panels/PanelsReaderState.kt | 79 ++++++++++++------- 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index 4a4d2cc4..e2118a5f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -146,7 +146,10 @@ fun BoxScope.PanelsReaderContent( imageResultState.value = panelsReaderState.getImage(pageMeta) } - Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { ReaderImageContent(imageResultState.value) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index a0f42873..971091a1 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -114,15 +114,46 @@ class PanelsReaderState( screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) - combine( - screenScaleState.transformation, - screenScaleState.areaSize, - ) {} - .drop(1).conflate() + var lastAreaSize = screenScaleState.areaSize.value + screenScaleState.areaSize + .drop(1) + .onEach { areaSize -> + val page = currentPage.value ?: return@onEach + val oldSize = lastAreaSize + lastAreaSize = areaSize + if (areaSize == IntSize.Zero || areaSize == oldSize) return@onEach + + // Small delay to allow Compose layout to finish centering the content + // before we calculate the required transformation + delay(100) + + val panelIdx = currentPageIndex.value.panel + val panelData = page.panelData + val image = (page.imageResult as? ReaderImageResult.Success)?.image + if (panelData != null && image != null) { + val stretchToFit = readerState.imageStretchToFit.value + val imageDisplaySize = image.calculateSizeForArea(areaSize, stretchToFit) + if (imageDisplaySize != null) { + screenScaleState.setTargetSize(imageDisplaySize.toSize()) + val panel = panelData.panels.getOrNull(panelIdx) ?: panelData.panels.first() + scrollToPanel( + imageSize = panelData.originalImageSize, + screenSize = areaSize, + targetSize = imageDisplaySize, + panel = panel, + skipAnimation = false + ) + } + } + } + .launchIn(stateScope) + + screenScaleState.transformation + .drop(1) + .conflate() .onEach { currentPage.value?.let { page -> updateImageState(page, screenScaleState, currentPageIndex.value.panel) - delay(100) } } .launchIn(stateScope) @@ -582,36 +613,28 @@ class PanelsReaderState( val xScale: Float = targetSize.width.toFloat() / imageSize.width val yScale: Float = targetSize.height.toFloat() / imageSize.height - val bboxLeft: Float = panel.left.coerceAtLeast(0) * xScale - val bboxRight: Float = panel.right.coerceAtMost(imageSize.width) * xScale - val bboxBottom: Float = panel.bottom.coerceAtMost(imageSize.height) * yScale - val bboxTop: Float = panel.top.coerceAtLeast(0) * yScale - val bboxWidth: Float = bboxRight - bboxLeft - val bboxHeight: Float = bboxBottom - bboxTop + val panelCenterX = (panel.left + panel.width / 2f) * xScale + val panelCenterY = (panel.top + panel.height / 2f) * yScale + val imageCenterX = targetSize.width / 2f + val imageCenterY = targetSize.height / 2f - val scale: Float = min( + val bboxWidth = panel.width * xScale + val bboxHeight = panel.height * yScale + + val totalScale: Float = min( areaSize.width / bboxWidth, areaSize.height / bboxHeight ) - val fitToScreenScale = max( + val scaleFor100PercentZoom = max( areaSize.width.toFloat() / targetSize.width, areaSize.height.toFloat() / targetSize.height ) - val zoom: Float = scale / fitToScreenScale - - val bboxHalfWidth: Float = bboxWidth / 2.0f - val bboxHalfHeight: Float = bboxHeight / 2.0f - val imageHalfWidth: Float = targetSize.width / 2.0f - val imageHalfHeight: Float = targetSize.height / 2.0f - - val centerX: Float = (bboxLeft - imageHalfWidth) * -1.0f - val centerY: Float = (bboxTop - imageHalfHeight) * -1.0f - val offset = Offset( - (centerX - bboxHalfWidth) * scale, - (centerY - bboxHalfHeight) * scale - ) + val zoom: Float = totalScale / scaleFor100PercentZoom + + val offsetX = (imageCenterX - panelCenterX) * totalScale + val offsetY = (imageCenterY - panelCenterY) * totalScale - return offset to zoom + return Offset(offsetX, offsetY) to zoom } data class PanelsPage( From c41708f301cc1c47a5973aa5728802748419f494 Mon Sep 17 00:00:00 2001 From: eserero Date: Sun, 1 Mar 2026 15:31:33 +0200 Subject: [PATCH 23/35] feat(reader): add adaptive edge-sampled gradient backgrounds - Add efficient edge-sampling color utility using libvips. - Implement AdaptiveBackground Composable with animated transitions. - Integrate adaptive backgrounds into Paged and Panel reader modes. - Add settings toggles and persistence for both reader modes. - Include database migration (V18) for new settings. --- ADAPTIVE_READER_BACKGROUND_PLAN.md | 70 +++++++++++++++++++ .../settings/ImageReaderSettingsRepository.kt | 6 ++ .../snd/komelia/db/ImageReaderSettings.kt | 11 +-- .../ReaderSettingsRepositoryWrapper.kt | 16 +++++ .../app/V18__reader_adaptive_background.sql | 2 + .../komelia/db/migrations/AppMigrations.kt | 1 + .../ExposedImageReaderSettingsRepository.kt | 4 ++ .../db/tables/ImageReaderSettingsTable.kt | 2 + .../kotlin/snd/komelia/image/KomeliaImage.kt | 1 + .../snd/komelia/image/ReaderImageUtils.kt | 27 +++++++ .../snd/komelia/image/VipsImageDecoder.kt | 33 +++++++++ .../komelia/image/wasm/client/WorkerImage.kt | 31 ++++++++ .../reader/image/common/AdaptiveBackground.kt | 50 +++++++++++++ .../reader/image/paged/PagedReaderContent.kt | 68 ++++++++++++------ .../ui/reader/image/paged/PagedReaderState.kt | 50 +++++++++++-- .../image/panels/PanelsReaderContent.kt | 60 ++++++++++------ .../reader/image/panels/PanelsReaderState.kt | 45 ++++++++++-- .../settings/BottomSheetSettingsOverlay.kt | 16 +++++ .../reader/image/settings/SettingsSideMenu.kt | 14 ++++ .../snd/komelia/ui/strings/AppStrings.kt | 1 + .../snd/komelia/ui/strings/EnStrings.kt | 1 + 21 files changed, 450 insertions(+), 59 deletions(-) create mode 100644 ADAPTIVE_READER_BACKGROUND_PLAN.md create mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V18__reader_adaptive_background.sql create mode 100644 komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt create mode 100644 komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt diff --git a/ADAPTIVE_READER_BACKGROUND_PLAN.md b/ADAPTIVE_READER_BACKGROUND_PLAN.md new file mode 100644 index 00000000..779ea7db --- /dev/null +++ b/ADAPTIVE_READER_BACKGROUND_PLAN.md @@ -0,0 +1,70 @@ +# Adaptive Reader Background (Edge-Sampled Gradients) Implementation Plan + +This plan outlines the implementation of an "Adaptive Background" feature for the comic reader in Komelia. This feature improves visual immersion by replacing solid letterbox/pillarbox bars with a two-color gradient sampled from the current page's edges. + +## 1. Feature Overview +When a page does not perfectly fill the screen (due to "Fit to Screen" settings), the empty space (letterbox or pillarbox) will be filled with a gradient. +- **Top/Bottom gaps (Letterbox):** Vertical gradient from Top Edge Color to Bottom Edge Color. +- **Left/Right gaps (Pillarbox):** Horizontal gradient from Left Edge Color to Right Edge Color. +- **Panel Mode:** Uses the edge colors of the *full page* even when zoomed into a panel. +- **Configurability:** Independent toggles for Paged Mode and Panel Mode in Reader Settings. + +## 2. Technical Strategy + +### A. Color Sampling (Domain/Infra) +We need an efficient way to extract the average color of image edges using the `KomeliaImage` (libvips) abstraction. + +1. **Utility Function:** Create `getEdgeColors(image: KomeliaImage): Pair` (or similar) in `komelia-infra/image-decoder`. +2. **Implementation:** + - To get Top/Bottom colors: + - Extract a small horizontal strip from the top (e.g., full width, 10px height). + - Shrink the strip to 1x1. + - Repeat for the bottom. + - To get Left/Right colors: + - Extract a vertical strip from the left (e.g., 10px width, full height). + - Shrink to 1x1. + - Repeat for the right. +3. **Efficiency:** Libvips is optimized for these operations; it will avoid full decompression where possible and perform the resize/averaging in a streaming fashion. + +### B. State Management +1. **Settings:** + - Add `pagedReaderAdaptiveBackground` and `panelReaderAdaptiveBackground` to `ImageReaderSettingsRepository`. + - Update `PagedReaderState` and `PanelsReaderState` to collect these settings. +2. **Page State:** + - Add `edgeColors: Pair?` to the `Page` data class in `PagedReaderState`. + - When a page is loaded, trigger the background sampling task asynchronously. + - `PanelsReaderState` will track the edge colors of the *current page* it is showing panels for. + +### C. UI Implementation (Compose) +1. **AdaptiveBackground Composable:** + - Location: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/` + - Parameters: `topColor: Color`, `bottomColor: Color`, `orientation: Orientation`. + - Use `animateColorAsState` for smooth transitions between pages. + - Use `Brush.linearGradient` to draw the background. +2. **Integration:** + - Wrap `ReaderImageContent` in `PagedReaderContent` and `PanelsReaderContent` with the new `AdaptiveBackground` component. + - Pass the colors based on whether the feature is enabled in the current mode's settings. + +### D. Settings UI +1. **Reader Settings:** + - Add two new toggles in `SettingsContent.kt` (used by `BottomSheetSettingsOverlay` and `SettingsSideMenu`). + - Labels: "Adaptive Background (Paged)" and "Adaptive Background (Panels)". + - Position them near "Double tap to zoom" for consistency. + +## 3. Implementation Steps + +1. **Infra:** Implement the color sampling logic in `komelia-infra/image-decoder`. +2. **Domain:** Update `ImageReaderSettingsRepository` interface and its implementation (e.g., `AndroidImageReaderSettingsRepository`). +3. **State:** + - Update `PagedReaderState` to perform color sampling when images are loaded. + - Update `PanelsReaderState` to share this logic for its current page. +4. **UI:** + - Create the `AdaptiveBackground` composable. + - Update the settings screens to include the toggles. + - Connect the state to the UI to render the gradients. + +## 4. Edge Cases & Considerations +- **Transparent Images:** Sampled colors should consider the background (likely white) if the image has transparency. +- **Very Thin Margins:** If the "Fit to Screen" fills the entire screen, the background won't be visible (current behavior preserved). +- **Performance:** Ensure sampling happens on a background thread and doesn't block the UI or delay page rendering. +- **Color Consistency:** Sampled colors can be slightly desaturated or darkened if they are too bright and distracting. diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt index 997d60fd..9c9ee1d3 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt @@ -88,4 +88,10 @@ interface ImageReaderSettingsRepository { fun getPanelReaderTapToZoom(): Flow suspend fun putPanelReaderTapToZoom(enabled: Boolean) + + fun getPagedReaderAdaptiveBackground(): Flow + suspend fun putPagedReaderAdaptiveBackground(enabled: Boolean) + + fun getPanelReaderAdaptiveBackground(): Flow + suspend fun putPanelReaderAdaptiveBackground(enabled: Boolean) } \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt index 3fec7805..872edfac 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt @@ -41,8 +41,9 @@ data class ImageReaderSettings( val ortUpscalerDeviceId: Int = 0, val ortUpscalerTileSize: Int = 512, - val panelsFullPageDisplayMode: PanelsFullPageDisplayMode = PanelsFullPageDisplayMode.NONE, - val pagedReaderTapToZoom: Boolean = true, - val panelReaderTapToZoom: Boolean = true, - ) - \ No newline at end of file + val panelsFullPageDisplayMode: PanelsFullPageDisplayMode = PanelsFullPageDisplayMode.NONE, + val pagedReaderTapToZoom: Boolean = true, + val panelReaderTapToZoom: Boolean = true, + val pagedReaderAdaptiveBackground: Boolean = false, + val panelReaderAdaptiveBackground: Boolean = false, +) \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt index 2dd4c5b3..4462efd8 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt @@ -219,4 +219,20 @@ class ReaderSettingsRepositoryWrapper( override suspend fun putPanelReaderTapToZoom(enabled: Boolean) { wrapper.transform { it.copy(panelReaderTapToZoom = enabled) } } + + override fun getPagedReaderAdaptiveBackground(): Flow { + return wrapper.mapState { it.pagedReaderAdaptiveBackground } + } + + override suspend fun putPagedReaderAdaptiveBackground(enabled: Boolean) { + wrapper.transform { it.copy(pagedReaderAdaptiveBackground = enabled) } + } + + override fun getPanelReaderAdaptiveBackground(): Flow { + return wrapper.mapState { it.panelReaderAdaptiveBackground } + } + + override suspend fun putPanelReaderAdaptiveBackground(enabled: Boolean) { + wrapper.transform { it.copy(panelReaderAdaptiveBackground = enabled) } + } } \ No newline at end of file diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V18__reader_adaptive_background.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V18__reader_adaptive_background.sql new file mode 100644 index 00000000..42c68200 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V18__reader_adaptive_background.sql @@ -0,0 +1,2 @@ +ALTER TABLE ImageReaderSettings ADD COLUMN paged_reader_adaptive_background BOOLEAN DEFAULT 0; +ALTER TABLE ImageReaderSettings ADD COLUMN panel_reader_adaptive_background BOOLEAN DEFAULT 0; diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt index 39f76fed..4e4b5e2d 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt @@ -24,6 +24,7 @@ class AppMigrations : MigrationResourcesProvider() { "V15__new_library_ui.sql", "V16__panel_reader_settings.sql", "V17__reader_tap_settings.sql", + "V18__reader_adaptive_background.sql", ) override suspend fun getMigration(name: String): ByteArray? { diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt index 32f65316..83887af1 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt @@ -58,6 +58,8 @@ class ExposedImageReaderSettingsRepository(database: Database) : ExposedReposito .let { mode -> PanelsFullPageDisplayMode.valueOf(mode) }, pagedReaderTapToZoom = it[ImageReaderSettingsTable.pagedReaderTapToZoom], panelReaderTapToZoom = it[ImageReaderSettingsTable.panelReaderTapToZoom], + pagedReaderAdaptiveBackground = it[ImageReaderSettingsTable.pagedReaderAdaptiveBackground], + panelReaderAdaptiveBackground = it[ImageReaderSettingsTable.panelReaderAdaptiveBackground], ) } } @@ -92,6 +94,8 @@ class ExposedImageReaderSettingsRepository(database: Database) : ExposedReposito it[panelsFullPageDisplayMode] = settings.panelsFullPageDisplayMode.name it[pagedReaderTapToZoom] = settings.pagedReaderTapToZoom it[panelReaderTapToZoom] = settings.panelReaderTapToZoom + it[pagedReaderAdaptiveBackground] = settings.pagedReaderAdaptiveBackground + it[panelReaderAdaptiveBackground] = settings.panelReaderAdaptiveBackground } } } diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt index 05d6fc79..2b846cd7 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt @@ -37,6 +37,8 @@ object ImageReaderSettingsTable : Table("ImageReaderSettings") { val panelsFullPageDisplayMode = text("panels_full_page_display_mode").default("NONE") val pagedReaderTapToZoom = bool("paged_reader_tap_to_zoom").default(true) val panelReaderTapToZoom = bool("panel_reader_tap_to_zoom").default(true) + val pagedReaderAdaptiveBackground = bool("paged_reader_adaptive_background").default(false) + val panelReaderAdaptiveBackground = bool("panel_reader_adaptive_background").default(false) override val primaryKey = PrimaryKey(bookId) } \ No newline at end of file diff --git a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt index cb0fdd97..1397b576 100644 --- a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt +++ b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt @@ -27,6 +27,7 @@ interface KomeliaImage : AutoCloseable { suspend fun mapLookupTable(table: ByteArray): KomeliaImage suspend fun getBytes(): ByteArray + suspend fun averageColor(): Int? } data class ImageDimensions( diff --git a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt new file mode 100644 index 00000000..d27d6ef2 --- /dev/null +++ b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt @@ -0,0 +1,27 @@ +package snd.komelia.image + +suspend fun KomeliaImage.getEdgeColors(vertical: Boolean): Pair? { + return if (vertical) { + val top = extractArea(ImageRect(0, 0, width, 10.coerceAtMost(height))) + val topColor = top.averageColor() + top.close() + + val bottom = extractArea(ImageRect(0, (height - 10).coerceAtLeast(0), width, height)) + val bottomColor = bottom.averageColor() + bottom.close() + + if (topColor != null && bottomColor != null) topColor to bottomColor + else null + } else { + val left = extractArea(ImageRect(0, 0, 10.coerceAtMost(width), height)) + val leftColor = left.averageColor() + left.close() + + val right = extractArea(ImageRect((width - 10).coerceAtLeast(0), 0, width, height)) + val rightColor = right.averageColor() + right.close() + + if (leftColor != null && rightColor != null) leftColor to rightColor + else null + } +} diff --git a/komelia-infra/image-decoder/vips/src/commonMain/kotlin/snd/komelia/image/VipsImageDecoder.kt b/komelia-infra/image-decoder/vips/src/commonMain/kotlin/snd/komelia/image/VipsImageDecoder.kt index 88b9506d..1577dbfb 100644 --- a/komelia-infra/image-decoder/vips/src/commonMain/kotlin/snd/komelia/image/VipsImageDecoder.kt +++ b/komelia-infra/image-decoder/vips/src/commonMain/kotlin/snd/komelia/image/VipsImageDecoder.kt @@ -123,6 +123,39 @@ class VipsBackedImage(val vipsImage: VipsImage) : KomeliaImage { return vipsImage.getBytes() } + override suspend fun averageColor(): Int? { + return withContext(Dispatchers.Default) { + val resized = vipsImage.resize(1, 1, null, false) + val bytes = resized.getBytes() + resized.close() + if (bytes.isEmpty()) return@withContext null + + when (bands) { + 4 -> { + val r = bytes[0].toInt() and 0xFF + val g = bytes[1].toInt() and 0xFF + val b = bytes[2].toInt() and 0xFF + val a = bytes[3].toInt() and 0xFF + (a shl 24) or (r shl 16) or (g shl 8) or b + } + + 3 -> { + val r = bytes[0].toInt() and 0xFF + val g = bytes[1].toInt() and 0xFF + val b = bytes[2].toInt() and 0xFF + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + 1 -> { + val v = bytes[0].toInt() and 0xFF + (0xFF shl 24) or (v shl 16) or (v shl 8) or v + } + + else -> null + } + } + } + override suspend fun shrink(factor: Double): KomeliaImage { return withContext(Dispatchers.Default) { VipsBackedImage(vipsImage.shrink(factor)) diff --git a/komelia-infra/image-decoder/wasm-image-worker/src/wasmJsMain/kotlin/snd/komelia/image/wasm/client/WorkerImage.kt b/komelia-infra/image-decoder/wasm-image-worker/src/wasmJsMain/kotlin/snd/komelia/image/wasm/client/WorkerImage.kt index b1eb39f8..4f15f408 100644 --- a/komelia-infra/image-decoder/wasm-image-worker/src/wasmJsMain/kotlin/snd/komelia/image/wasm/client/WorkerImage.kt +++ b/komelia-infra/image-decoder/wasm-image-worker/src/wasmJsMain/kotlin/snd/komelia/image/wasm/client/WorkerImage.kt @@ -105,6 +105,37 @@ class WorkerImage( return result.bytes.asByteArray() } + override suspend fun averageColor(): Int? { + val resized = resize(1, 1, false, ReduceKernel.DEFAULT) + val bytes = resized.getBytes() + resized.close() + if (bytes.isEmpty()) return null + + return when (bands) { + 4 -> { + val r = bytes[0].toInt() and 0xFF + val g = bytes[1].toInt() and 0xFF + val b = bytes[2].toInt() and 0xFF + val a = bytes[3].toInt() and 0xFF + (a shl 24) or (r shl 16) or (g shl 8) or b + } + + 3 -> { + val r = bytes[0].toInt() and 0xFF + val g = bytes[1].toInt() and 0xFF + val b = bytes[2].toInt() and 0xFF + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + 1 -> { + val v = bytes[0].toInt() and 0xFF + (0xFF shl 24) or (v shl 16) or (v shl 8) or v + } + + else -> null + } + } + override fun close() { coroutineScope.launch { val message = closeImageRequest(worker.getNextId(), imageId) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt new file mode 100644 index 00000000..d51221d4 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt @@ -0,0 +1,50 @@ +package snd.komelia.ui.reader.image.common + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +@Composable +fun AdaptiveBackground( + edgeColors: Pair?, + isVerticalGaps: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val topColor = remember(edgeColors) { edgeColors?.first?.let { Color(it) } ?: Color.Transparent } + val bottomColor = remember(edgeColors) { edgeColors?.second?.let { Color(it) } ?: Color.Transparent } + + val animatedTop by animateColorAsState( + targetValue = topColor, + animationSpec = tween(durationMillis = 500) + ) + val animatedBottom by animateColorAsState( + targetValue = bottomColor, + animationSpec = tween(durationMillis = 500) + ) + + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { + if (edgeColors != null) { + val brush = if (isVerticalGaps) { + Brush.verticalGradient(listOf(animatedTop, animatedBottom)) + } else { + Brush.horizontalGradient(listOf(animatedTop, animatedBottom)) + } + drawRect(brush) + } + } + ) { + content() + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index 8f1b50e8..6153de7e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -52,6 +52,7 @@ import snd.komelia.ui.reader.image.paged.PagedReaderState.Page import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart +import snd.komelia.ui.reader.image.common.AdaptiveBackground import kotlin.math.abs @Composable @@ -81,6 +82,7 @@ fun BoxScope.PagedReaderContent( val layout = pagedReaderState.layout.collectAsState().value val layoutOffset = pagedReaderState.layoutOffset.collectAsState().value val tapToZoom = pagedReaderState.tapToZoom.collectAsState().value + val adaptiveBackground = pagedReaderState.adaptiveBackground.collectAsState().value val currentContainerSize = screenScaleState.areaSize.collectAsState().value @@ -167,35 +169,55 @@ fun BoxScope.PagedReaderContent( TransitionPage(transitionPage) } else { if (spreads.isNotEmpty()) { - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - reverseLayout = readingDirection == RIGHT_TO_LEFT, - modifier = Modifier.fillMaxSize(), - key = { if (it < spreads.size) spreads[it].first().pageNumber else it } - ) { pageIdx -> - if (pageIdx >= spreads.size) return@HorizontalPager - val spreadMetadata = spreads[pageIdx] - val spreadPages = remember(spreadMetadata) { - spreadMetadata.map { meta -> - val imageResult = mutableStateOf(null) - meta to imageResult + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + reverseLayout = readingDirection == RIGHT_TO_LEFT, + modifier = Modifier.fillMaxSize(), + key = { if (it < spreads.size) spreads[it].first().pageNumber else it } + ) { pageIdx -> + if (pageIdx >= spreads.size) return@HorizontalPager + val spreadMetadata = spreads[pageIdx] + val spreadPages = remember(spreadMetadata) { + spreadMetadata.map { meta -> + val pageState = mutableStateOf(null) + meta to pageState + } } - } - spreadPages.forEach { (meta, imageResultState) -> - LaunchedEffect(meta) { - imageResultState.value = pagedReaderState.getImage(meta) + spreadPages.forEach { (meta, pageState) -> + LaunchedEffect(meta) { + pageState.value = pagedReaderState.getPage(meta) + } } - } - val pages = spreadPages.map { (meta, resultState) -> Page(meta, resultState.value) } + val pages = spreadPages.map { (meta, pageState) -> + pageState.value ?: Page(meta, null) + } - when (layout) { - SINGLE_PAGE -> pages.firstOrNull()?.let { SinglePageLayout(it) } - DOUBLE_PAGES, DOUBLE_PAGES_NO_COVER -> DoublePageLayout(pages, readingDirection) + val edgeColors = if (adaptiveBackground && pages.size == 1) pages.first().edgeColors else null + val isVerticalGaps = remember(pages, currentContainerSize) { + val imageSize = pages.firstOrNull()?.imageResult?.let { + if (it is ReaderImageResult.Success) it.image.displaySize.value else null + } + if (imageSize == null || currentContainerSize.width == 0 || currentContainerSize.height == 0) true + else { + val containerRatio = currentContainerSize.width.toDouble() / currentContainerSize.height + val imageRatio = imageSize.width.toDouble() / imageSize.height + imageRatio < containerRatio + } + } + + AdaptiveBackground( + edgeColors = edgeColors, + isVerticalGaps = isVerticalGaps, + ) { + when (layout) { + SINGLE_PAGE -> pages.firstOrNull()?.let { SinglePageLayout(it) } + DOUBLE_PAGES, DOUBLE_PAGES_NO_COVER -> DoublePageLayout(pages, readingDirection) + } + } } - } } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index 3b886c40..72f4eb58 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -33,6 +33,7 @@ import snd.komelia.AppNotifications import snd.komelia.image.BookImageLoader import snd.komelia.image.ReaderImage.PageId import snd.komelia.image.ReaderImageResult +import snd.komelia.image.getEdgeColors import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.settings.ImageReaderSettingsRepository import snd.komelia.settings.model.LayoutScaleType @@ -92,6 +93,7 @@ class PagedReaderState( val scaleType = MutableStateFlow(LayoutScaleType.SCREEN) val readingDirection = MutableStateFlow(LEFT_TO_RIGHT) val tapToZoom = MutableStateFlow(true) + val adaptiveBackground = MutableStateFlow(false) val pageNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1) @@ -104,6 +106,7 @@ class PagedReaderState( else -> settingsRepository.getPagedReaderReadingDirection().first() } tapToZoom.value = settingsRepository.getPagedReaderTapToZoom().first() + adaptiveBackground.value = settingsRepository.getPagedReaderAdaptiveBackground().first() screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) @@ -374,7 +377,22 @@ class PagedReaderState( if (cached != null && !cached.isCancelled) cached else pageLoadScope.async { val imageResult = imageLoader.loadReaderImage(meta.bookId, meta.pageNumber) - Page(meta, imageResult) + val edgeColors = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { + val originalImage = imageResult.image.getOriginalImage().getOrNull() + if (originalImage != null) { + val containerSize = screenScaleState.areaSize.value + val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true + else { + val containerRatio = containerSize.width.toDouble() / containerSize.height + val imageRatio = originalImage.width.toDouble() / originalImage.height + imageRatio < containerRatio + } + + val colors = originalImage.getEdgeColors(isVerticalGaps) + colors + } else null + } else null + Page(meta, imageResult, edgeColors) }.also { imageCache.put(pageId, it) } } @@ -422,17 +440,31 @@ class PagedReaderState( launchSpreadLoadJob(pagesMeta) } - suspend fun getImage(page: PageMetadata): ReaderImageResult { + suspend fun getPage(page: PageMetadata): Page { val pageId = page.toPageId() val cached = imageCache.get(pageId) return if (cached != null && !cached.isCancelled) { - cached.await().imageResult ?: ReaderImageResult.Error(Exception("Image result is null")) + cached.await() } else { val job = pageLoadScope.async { - val result = imageLoader.loadReaderImage(page.bookId, page.pageNumber) - Page(page, result) + val imageResult = imageLoader.loadReaderImage(page.bookId, page.pageNumber) + val edgeColors = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { + val originalImage = imageResult.image.getOriginalImage().getOrNull() + if (originalImage != null) { + val containerSize = screenScaleState.areaSize.value + val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true + else { + val containerRatio = containerSize.width.toDouble() / containerSize.height + val imageRatio = originalImage.width.toDouble() / originalImage.height + imageRatio < containerRatio + } + + originalImage.getEdgeColors(isVerticalGaps) + } else null + } else null + Page(page, imageResult, edgeColors) }.also { imageCache.put(pageId, it) } - job.await().imageResult ?: ReaderImageResult.Error(Exception("Image result is null")) + job.await() } } @@ -577,6 +609,11 @@ class PagedReaderState( stateScope.launch { settingsRepository.putPagedReaderTapToZoom(enabled) } } + fun onAdaptiveBackgroundChange(enabled: Boolean) { + this.adaptiveBackground.value = enabled + stateScope.launch { settingsRepository.putPagedReaderAdaptiveBackground(enabled) } + } + private suspend fun calculateScreenScale( pages: List, areaSize: IntSize, @@ -706,6 +743,7 @@ class PagedReaderState( data class Page( val metadata: PageMetadata, val imageResult: ReaderImageResult?, + val edgeColors: Pair? = null, ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index e2118a5f..a1e77a2b 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -46,6 +46,7 @@ import snd.komelia.ui.reader.image.paged.PagedReaderState.PageNavigationEvent import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart +import snd.komelia.ui.reader.image.common.AdaptiveBackground @Composable fun BoxScope.PanelsReaderContent( @@ -73,6 +74,7 @@ fun BoxScope.PanelsReaderContent( val currentPageIndex = panelsReaderState.currentPageIndex.collectAsState().value val currentContainerSize = screenScaleState.areaSize.collectAsState().value val tapToZoom = panelsReaderState.tapToZoom.collectAsState().value + val adaptiveBackground = panelsReaderState.adaptiveBackground.collectAsState().value val pagerState = rememberPagerState( initialPage = currentPageIndex.page, @@ -131,28 +133,46 @@ fun BoxScope.PanelsReaderContent( TransitionPage(transitionPage) } else { if (metadata.isNotEmpty()) { - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - reverseLayout = readingDirection == RIGHT_TO_LEFT, - modifier = Modifier.fillMaxSize(), - key = { if (it < metadata.size) metadata[it].pageNumber else it } - ) { pageIdx -> - if (pageIdx >= metadata.size) return@HorizontalPager - val pageMeta = metadata[pageIdx] - - val imageResultState = remember(pageMeta) { mutableStateOf(null) } - LaunchedEffect(pageMeta) { - imageResultState.value = panelsReaderState.getImage(pageMeta) - } - - Box( + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + reverseLayout = readingDirection == RIGHT_TO_LEFT, modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - ReaderImageContent(imageResultState.value) + key = { if (it < metadata.size) metadata[it].pageNumber else it } + ) { pageIdx -> + if (pageIdx >= metadata.size) return@HorizontalPager + val pageMeta = metadata[pageIdx] + + val pageState = remember(pageMeta) { mutableStateOf(null) } + LaunchedEffect(pageMeta) { + pageState.value = panelsReaderState.getPage(pageMeta) + } + + val edgeColors = if (adaptiveBackground) pageState.value?.edgeColors else null + val isVerticalGaps = remember(pageState.value, currentContainerSize) { + val imageSize = pageState.value?.imageResult?.let { + if (it is ReaderImageResult.Success) it.image.displaySize.value else null + } + if (imageSize == null || currentContainerSize.width == 0 || currentContainerSize.height == 0) true + else { + val containerRatio = currentContainerSize.width.toDouble() / currentContainerSize.height + val imageRatio = imageSize.width.toDouble() / imageSize.height + imageRatio < containerRatio + } + } + + AdaptiveBackground( + edgeColors = edgeColors, + isVerticalGaps = isVerticalGaps, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ReaderImageContent(pageState.value?.imageResult) + } + } } - } } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index 971091a1..80574234 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -37,6 +37,7 @@ import snd.komelia.image.ImageRect import snd.komelia.image.KomeliaPanelDetector import snd.komelia.image.ReaderImage.PageId import snd.komelia.image.ReaderImageResult +import snd.komelia.image.getEdgeColors import snd.komelia.onnxruntime.OnnxRuntimeException import snd.komelia.settings.ImageReaderSettingsRepository import snd.komelia.settings.model.PagedReadingDirection @@ -99,6 +100,7 @@ class PanelsReaderState( val fullPageDisplayMode = MutableStateFlow(PanelsFullPageDisplayMode.NONE) val tapToZoom = MutableStateFlow(true) + val adaptiveBackground = MutableStateFlow(false) val pageNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1) @@ -110,6 +112,7 @@ class PanelsReaderState( } fullPageDisplayMode.value = settingsRepository.getPanelsFullPageDisplayMode().first() tapToZoom.value = settingsRepository.getPanelReaderTapToZoom().first() + adaptiveBackground.value = settingsRepository.getPanelReaderAdaptiveBackground().first() screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) @@ -183,6 +186,17 @@ class PanelsReaderState( this.density = density } + suspend fun getPage(page: PageMetadata): PanelsPage { + val pageId = page.toPageId() + val cached = imageCache.get(pageId) + return if (cached != null && !cached.isCancelled) { + cached.await() + } else { + val job = launchDownload(page) + job.await() + } + } + suspend fun getImage(page: PageMetadata): ReaderImageResult { val pageId = page.toPageId() val cached = imageCache.get(pageId) @@ -269,6 +283,11 @@ class PanelsReaderState( stateScope.launch { settingsRepository.putPanelReaderTapToZoom(enabled) } } + fun onAdaptiveBackgroundChange(enabled: Boolean) { + this.adaptiveBackground.value = enabled + stateScope.launch { settingsRepository.putPanelReaderAdaptiveBackground(enabled) } + } + fun nextPanel() { val pageIndex = currentPageIndex.value val currentPage = currentPage.value @@ -413,11 +432,13 @@ class PanelsReaderState( it?.copy( metadata = pageMeta, imageResult = null, - panelData = null + panelData = null, + edgeColors = null ) ?: PanelsPage( metadata = pageMeta, imageResult = null, - panelData = null + panelData = null, + edgeColors = null ) } currentPageIndex.update { PageIndex(pageIndex, 0) } @@ -514,8 +535,20 @@ class PanelsReaderState( panelData = null ) - val imageSize = IntSize(originalImage.width, originalImage.height) - val (panels, duration) = measureTimedValue { + val imageSize = IntSize(originalImage.width, originalImage.height) + val edgeColors = if (adaptiveBackground.value) { + val containerSize = screenScaleState.areaSize.value + val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true + else { + val containerRatio = containerSize.width.toDouble() / containerSize.height + val imageRatio = imageSize.width.toDouble() / imageSize.height + imageRatio < containerRatio + } + originalImage.getEdgeColors(isVerticalGaps) + } else null + + val (panels, duration) = measureTimedValue { + try { onnxRuntimeRfDetr.detect(originalImage).map { it.boundingBox } } catch (e: OnnxRuntimeException) { @@ -537,7 +570,8 @@ class PanelsReaderState( return@async PanelsPage( metadata = meta, imageResult = imageResult, - panelData = panelData + panelData = panelData, + edgeColors = edgeColors ) } imageCache.put(pageId, loadJob) @@ -641,6 +675,7 @@ class PanelsReaderState( val metadata: PageMetadata, val imageResult: ReaderImageResult?, val panelData: PanelData?, + val edgeColors: Pair? = null, ) data class PanelData( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt index 720ad06b..5c3d8794 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt @@ -310,6 +310,7 @@ private fun PagedModeSettings( val strings = LocalStrings.current.pagedReader val scaleType = pageState.scaleType.collectAsState().value val tapToZoom = pageState.tapToZoom.collectAsState().value + val adaptiveBackground = pageState.adaptiveBackground.collectAsState().value Column { Text(strings.scaleType) @@ -393,6 +394,13 @@ private fun PagedModeSettings( label = { Text("Tap to zoom") }, contentPadding = PaddingValues(horizontal = 10.dp), ) + + SwitchWithLabel( + checked = adaptiveBackground, + onCheckedChange = pageState::onAdaptiveBackgroundChange, + label = { Text(strings.adaptiveBackground) }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) } } @@ -404,6 +412,7 @@ private fun PanelsModeSettings( ) { val strings = LocalStrings.current.pagedReader val tapToZoom = state.tapToZoom.collectAsState().value + val adaptiveBackground = state.adaptiveBackground.collectAsState().value Column { val readingDirection = state.readingDirection.collectAsState().value @@ -441,6 +450,13 @@ private fun PanelsModeSettings( label = { Text("Tap to zoom") }, contentPadding = PaddingValues(horizontal = 10.dp), ) + + SwitchWithLabel( + checked = adaptiveBackground, + onCheckedChange = state::onAdaptiveBackgroundChange, + label = { Text(strings.adaptiveBackground) }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt index d53699c1..6d71d681 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt @@ -424,12 +424,19 @@ private fun ColumnScope.PagedReaderSettingsContent( } val tapToZoom = pageState.tapToZoom.collectAsState().value + val adaptiveBackground = pageState.adaptiveBackground.collectAsState().value SwitchWithLabel( checked = tapToZoom, onCheckedChange = pageState::onTapToZoomChange, label = { Text("Tap to zoom") }, contentPadding = PaddingValues(horizontal = 10.dp) ) + SwitchWithLabel( + checked = adaptiveBackground, + onCheckedChange = pageState::onAdaptiveBackgroundChange, + label = { Text(strings.adaptiveBackground) }, + contentPadding = PaddingValues(horizontal = 10.dp) + ) } } @@ -441,6 +448,7 @@ private fun PanelsReaderSettingsContent( val readingDirection = state.readingDirection.collectAsState().value val displayMode = state.fullPageDisplayMode.collectAsState().value val tapToZoom = state.tapToZoom.collectAsState().value + val adaptiveBackground = state.adaptiveBackground.collectAsState().value Column { @@ -475,6 +483,12 @@ private fun PanelsReaderSettingsContent( label = { Text("Tap to zoom") }, contentPadding = PaddingValues(horizontal = 10.dp) ) + SwitchWithLabel( + checked = adaptiveBackground, + onCheckedChange = state::onAdaptiveBackgroundChange, + label = { Text(strings.adaptiveBackground) }, + contentPadding = PaddingValues(horizontal = 10.dp) + ) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/AppStrings.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/AppStrings.kt index 14a26a38..3739e42f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/AppStrings.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/AppStrings.kt @@ -303,6 +303,7 @@ data class PagedReaderStrings( val layoutDoublePages: String, val layoutDoublePagesNoCover: String, val offsetPages: String, + val adaptiveBackground: String, ) { fun forScaleType(type: LayoutScaleType): String { return when (type) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/EnStrings.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/EnStrings.kt index 3f072500..5ad5b503 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/EnStrings.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/EnStrings.kt @@ -176,6 +176,7 @@ val EnStrings = AppStrings( layoutDoublePages = "Double pages", layoutDoublePagesNoCover = "Double pages (no cover)", offsetPages = "Offset pages", + adaptiveBackground = "Adaptive background", ), continuousReader = ContinuousReaderStrings( sidePadding = "Side padding", From 928e32334ab92c6b3e345b2ade6bef171ede39d3 Mon Sep 17 00:00:00 2001 From: eserero Date: Sun, 1 Mar 2026 15:58:10 +0200 Subject: [PATCH 24/35] fix(reader): correct inverted logic for adaptive background sampling --- .../snd/komelia/ui/reader/image/paged/PagedReaderState.kt | 4 ++-- .../snd/komelia/ui/reader/image/panels/PanelsReaderState.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index 72f4eb58..f09bd2ae 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -385,7 +385,7 @@ class PagedReaderState( else { val containerRatio = containerSize.width.toDouble() / containerSize.height val imageRatio = originalImage.width.toDouble() / originalImage.height - imageRatio < containerRatio + imageRatio > containerRatio } val colors = originalImage.getEdgeColors(isVerticalGaps) @@ -456,7 +456,7 @@ class PagedReaderState( else { val containerRatio = containerSize.width.toDouble() / containerSize.height val imageRatio = originalImage.width.toDouble() / originalImage.height - imageRatio < containerRatio + imageRatio > containerRatio } originalImage.getEdgeColors(isVerticalGaps) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index 80574234..45fdce6e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -542,7 +542,7 @@ class PanelsReaderState( else { val containerRatio = containerSize.width.toDouble() / containerSize.height val imageRatio = imageSize.width.toDouble() / imageSize.height - imageRatio < containerRatio + imageRatio > containerRatio } originalImage.getEdgeColors(isVerticalGaps) } else null From 9bdc9d9d0f1be3c3bf603232a1efd6a9b83f2273 Mon Sep 17 00:00:00 2001 From: eserero Date: Sun, 1 Mar 2026 17:42:04 +0200 Subject: [PATCH 25/35] feat(reader): implement image-relative adaptive background blooming This commit implements Phase 2 and 2.5 of the adaptive background plan: - Add EdgeSampling and EdgeSample data classes for richer edge data. - Update ReaderImageUtils to efficiently sample edge average colors. - Pre-calculate image display sizes in reader states to avoid composition-time suspend calls. - Rewrite AdaptiveBackground to draw gradients relative to image edges rather than screen center. - Implement sub-pixel rounding fixes and 1px overlap to eliminate background gaps. - Add ByteArray.toImageBitmap extension for raw RGBA buffer conversion across platforms. --- ADAPTIVE_READER_BACKGROUND_PLAN.md | 44 +++++++ .../snd/komelia/image/ImageBitmap.android.kt | 8 ++ .../kotlin/snd/komelia/image/ImageBitmap.kt | 2 + .../snd/komelia/image/ImageBitmap.jvm.kt | 12 +- .../snd/komelia/image/ImageBitmap.wasmJs.kt | 8 ++ .../kotlin/snd/komelia/image/KomeliaImage.kt | 11 ++ .../snd/komelia/image/ReaderImageUtils.kt | 82 +++++++++++++ .../reader/image/common/AdaptiveBackground.kt | 116 ++++++++++++++---- .../reader/image/paged/PagedReaderContent.kt | 18 +-- .../ui/reader/image/paged/PagedReaderState.kt | 31 +++-- .../image/panels/PanelsReaderContent.kt | 18 +-- .../reader/image/panels/PanelsReaderState.kt | 28 +++-- 12 files changed, 303 insertions(+), 75 deletions(-) diff --git a/ADAPTIVE_READER_BACKGROUND_PLAN.md b/ADAPTIVE_READER_BACKGROUND_PLAN.md index 779ea7db..8b95cb20 100644 --- a/ADAPTIVE_READER_BACKGROUND_PLAN.md +++ b/ADAPTIVE_READER_BACKGROUND_PLAN.md @@ -68,3 +68,47 @@ We need an efficient way to extract the average color of image edges using the ` - **Very Thin Margins:** If the "Fit to Screen" fills the entire screen, the background won't be visible (current behavior preserved). - **Performance:** Ensure sampling happens on a background thread and doesn't block the UI or delay page rendering. - **Color Consistency:** Sampled colors can be slightly desaturated or darkened if they are too bright and distracting. + +## Phase 2: Per-Pixel Edge Gradients (Blooming Effect) +This phase improves the background by preserving the color variation along the image edges and fading them into the theme's background. + +### 1. Technical Strategy +- **Sampling:** Instead of a single 1x1 average, sample a 1D line of colors. + - Top/Bottom: Extract 10px strip, resize to `width x 1`. + - Left/Right: Extract 10px strip, resize to `1 x height`. +- **Rendering:** + - Draw the 1D sampled line stretched across the gap (creating color bars). + - Apply a gradient overlay from `Transparent` (image side) to `ThemeBackground` (screen edge side). + +## Phase 2.5: Image-Relative Bloom Gradients +This fix ensures the background "bloom" starts exactly at the image edges rather than the center of the screen. + +### 1. Technical Strategy +- **Image Bounds:** Retrieve the actual display dimensions and position of the image within the container. +- **Top Bloom:** Start at the `image.top` with `Color.Transparent` (showing full colors) and fade to `MaterialTheme.colorScheme.background` at the screen `top (0)`. +- **Bottom Bloom:** Start at the `image.bottom` with `Color.Transparent` and fade to `MaterialTheme.colorScheme.background` at the screen `bottom (height)`. +- **Logic:** The "colorful" part of the background should "leak" from the image outward to the screen edges. + +### 2. Implementation Steps +1. **UI:** Update `AdaptiveBackground` to accept the `imageSize` or `imageBounds`. +2. **Rendering:** Recalculate the `drawRect` and `Brush` coordinates to align with these bounds. + +## Phase 3: Four-Side Sampling & Corner Blending (Panel Mode Optimization) +This phase addresses scenarios where gaps exist on all four sides of the image, which is common in Panel Mode. + +### 1. Technical Strategy +- **Sampling:** Always sample all four edges (Top, Bottom, Left, Right). +- **Rendering:** + - Create four independent gradient zones. + - **Corner Miter:** Use a 45-degree clipping or alpha-blending in the corners so that adjacent edge colors (e.g., Top and Left) meet seamlessly. +- **Panel Padding:** Ensure the background fills the additional "safety margin" or padding added around panels, providing a consistent immersive feel even when the panel is much smaller than the screen. + +### 2. Implementation Steps +1. **Infra:** Update sampling to return all four edge lines. +2. **UI:** Update `AdaptiveBackground` to render four zones with corner blending logic. +3. **Panel Mode:** Verify integration with panel zooming and padding logic. + +### 2. Implementation Steps +1. **Infra:** Add `getEdgeColorLines()` to `KomeliaImage` / `ReaderImageUtils`. +2. **UI:** Create a version of `AdaptiveBackground` that handles `ImageBitmap` buffers and applies the "bloom" fade. +3. **Switching:** Maintain both Phase 1 and Phase 2 logic for easy comparison/toggling during development. diff --git a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/ImageBitmap.android.kt b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/ImageBitmap.android.kt index f3826d2d..40ddb6e2 100644 --- a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/ImageBitmap.android.kt +++ b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/ImageBitmap.android.kt @@ -1,9 +1,17 @@ package snd.komelia.image +import android.graphics.Bitmap import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import snd.komelia.image.AndroidBitmap.toBitmap +import java.nio.ByteBuffer actual suspend fun KomeliaImage.toImageBitmap(): ImageBitmap { return this.toBitmap().asImageBitmap() +} + +actual fun ByteArray.toImageBitmap(width: Int, height: Int): ImageBitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(this)) + return bitmap.asImageBitmap() } \ No newline at end of file diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/image/ImageBitmap.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/image/ImageBitmap.kt index e3399c5a..ac8e58c1 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/image/ImageBitmap.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/image/ImageBitmap.kt @@ -3,3 +3,5 @@ package snd.komelia.image import androidx.compose.ui.graphics.ImageBitmap expect suspend fun KomeliaImage.toImageBitmap(): ImageBitmap + +expect fun ByteArray.toImageBitmap(width: Int, height: Int): ImageBitmap diff --git a/komelia-domain/core/src/jvmMain/kotlin/snd/komelia/image/ImageBitmap.jvm.kt b/komelia-domain/core/src/jvmMain/kotlin/snd/komelia/image/ImageBitmap.jvm.kt index 5cea1d2d..4dab5fe1 100644 --- a/komelia-domain/core/src/jvmMain/kotlin/snd/komelia/image/ImageBitmap.jvm.kt +++ b/komelia-domain/core/src/jvmMain/kotlin/snd/komelia/image/ImageBitmap.jvm.kt @@ -2,7 +2,17 @@ package snd.komelia.image import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo import snd.komelia.image.SkiaBitmap.toSkiaBitmap actual suspend fun KomeliaImage.toImageBitmap(): ImageBitmap = - this.toSkiaBitmap().asComposeImageBitmap() \ No newline at end of file + this.toSkiaBitmap().asComposeImageBitmap() + +actual fun ByteArray.toImageBitmap(width: Int, height: Int): ImageBitmap { + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo.makeS32(width, height, ColorAlphaType.PREMUL)) + bitmap.installPixels(this) + return bitmap.asComposeImageBitmap() +} \ No newline at end of file diff --git a/komelia-domain/core/src/wasmJsMain/kotlin/snd/komelia/image/ImageBitmap.wasmJs.kt b/komelia-domain/core/src/wasmJsMain/kotlin/snd/komelia/image/ImageBitmap.wasmJs.kt index 9d327bc7..589b97f9 100644 --- a/komelia-domain/core/src/wasmJsMain/kotlin/snd/komelia/image/ImageBitmap.wasmJs.kt +++ b/komelia-domain/core/src/wasmJsMain/kotlin/snd/komelia/image/ImageBitmap.wasmJs.kt @@ -12,6 +12,14 @@ import org.jetbrains.skia.ImageInfo actual suspend fun KomeliaImage.toImageBitmap(): ImageBitmap = this.toBitmap().asComposeImageBitmap() +actual fun ByteArray.toImageBitmap(width: Int, height: Int): ImageBitmap { + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo.makeS32(width, height, ColorAlphaType.PREMUL)) + bitmap.installPixels(this) + bitmap.setImmutable() + return bitmap.asComposeImageBitmap() +} + suspend fun KomeliaImage.toBitmap(): Bitmap { val colorInfo = when (type) { ImageFormat.GRAYSCALE_8 -> { diff --git a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt index 1397b576..e56ed3e5 100644 --- a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt +++ b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt @@ -63,3 +63,14 @@ enum class ReduceKernel { MKS2021, DEFAULT, } + +data class EdgeSampling( + val vertical: Boolean, + val first: EdgeSample, + val second: EdgeSample, +) + +data class EdgeSample( + val averageColor: Int, + val colorLine: ByteArray, +) diff --git a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt index d27d6ef2..c45d1087 100644 --- a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt +++ b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt @@ -25,3 +25,85 @@ suspend fun KomeliaImage.getEdgeColors(vertical: Boolean): Pair? { else null } } + +suspend fun KomeliaImage.getEdgeColorLines(vertical: Boolean): Pair? { + return if (vertical) { + val top = extractArea(ImageRect(0, 0, width, 10.coerceAtMost(height))) + val topResized = top.resize(width, 1) + val topBytes = topResized.getBytes() + top.close() + topResized.close() + + val bottom = extractArea(ImageRect(0, (height - 10).coerceAtLeast(0), width, height)) + val bottomResized = bottom.resize(width, 1) + val bottomBytes = bottomResized.getBytes() + bottom.close() + bottomResized.close() + + if (topBytes.isNotEmpty() && bottomBytes.isNotEmpty()) topBytes to bottomBytes + else null + } else { + val left = extractArea(ImageRect(0, 0, 10.coerceAtMost(width), height)) + val leftResized = left.resize(1, height) + val leftBytes = leftResized.getBytes() + left.close() + leftResized.close() + + val right = extractArea(ImageRect((width - 10).coerceAtLeast(0), 0, width, height)) + val rightResized = right.resize(1, height) + val rightBytes = rightResized.getBytes() + right.close() + rightResized.close() + + if (leftBytes.isNotEmpty() && rightBytes.isNotEmpty()) leftBytes to rightBytes + else null + } +} + +suspend fun KomeliaImage.getEdgeSampling(vertical: Boolean): EdgeSampling? { + return if (vertical) { + val top = extractArea(ImageRect(0, 0, width, 10.coerceAtMost(height))) + val topColor = top.averageColor() + val topResized = top.resize(1, 1) + val topBytes = topResized.getBytes() + top.close() + topResized.close() + + val bottom = extractArea(ImageRect(0, (height - 10).coerceAtLeast(0), width, height)) + val bottomColor = bottom.averageColor() + val bottomResized = bottom.resize(1, 1) + val bottomBytes = bottomResized.getBytes() + bottom.close() + bottomResized.close() + + if (topColor != null && topBytes.isNotEmpty() && bottomColor != null && bottomBytes.isNotEmpty()) { + EdgeSampling( + vertical = true, + first = EdgeSample(topColor, topBytes), + second = EdgeSample(bottomColor, bottomBytes) + ) + } else null + } else { + val left = extractArea(ImageRect(0, 0, 10.coerceAtMost(width), height)) + val leftColor = left.averageColor() + val leftResized = left.resize(1, 1) + val leftBytes = leftResized.getBytes() + left.close() + leftResized.close() + + val right = extractArea(ImageRect((width - 10).coerceAtLeast(0), 0, width, height)) + val rightColor = right.averageColor() + val rightResized = right.resize(1, 1) + val rightBytes = rightResized.getBytes() + right.close() + rightResized.close() + + if (leftColor != null && leftBytes.isNotEmpty() && rightColor != null && rightBytes.isNotEmpty()) { + EdgeSampling( + vertical = false, + first = EdgeSample(leftColor, leftBytes), + second = EdgeSample(rightColor, rightBytes) + ) + } else null + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt index d51221d4..3e9369ca 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt @@ -4,47 +4,115 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.IntSize +import snd.komelia.image.EdgeSampling @Composable fun AdaptiveBackground( - edgeColors: Pair?, - isVerticalGaps: Boolean, + edgeSampling: EdgeSampling?, modifier: Modifier = Modifier, + imageSize: IntSize? = null, content: @Composable () -> Unit ) { - val topColor = remember(edgeColors) { edgeColors?.first?.let { Color(it) } ?: Color.Transparent } - val bottomColor = remember(edgeColors) { edgeColors?.second?.let { Color(it) } ?: Color.Transparent } + val backgroundColor = MaterialTheme.colorScheme.background + val topColor = remember(edgeSampling) { + if (edgeSampling?.vertical == true) Color(edgeSampling.first.averageColor) else Color.Transparent + } + val bottomColor = remember(edgeSampling) { + if (edgeSampling?.vertical == true) Color(edgeSampling.second.averageColor) else Color.Transparent + } + val leftColor = remember(edgeSampling) { + if (edgeSampling?.vertical == false) Color(edgeSampling.first.averageColor) else Color.Transparent + } + val rightColor = remember(edgeSampling) { + if (edgeSampling?.vertical == false) Color(edgeSampling.second.averageColor) else Color.Transparent + } - val animatedTop by animateColorAsState( - targetValue = topColor, - animationSpec = tween(durationMillis = 500) - ) - val animatedBottom by animateColorAsState( - targetValue = bottomColor, - animationSpec = tween(durationMillis = 500) - ) + val animatedTop by animateColorAsState(targetValue = topColor, animationSpec = tween(durationMillis = 500)) + val animatedBottom by animateColorAsState(targetValue = bottomColor, animationSpec = tween(durationMillis = 500)) + val animatedLeft by animateColorAsState(targetValue = leftColor, animationSpec = tween(durationMillis = 500)) + val animatedRight by animateColorAsState(targetValue = rightColor, animationSpec = tween(durationMillis = 500)) - Box( - modifier = modifier - .fillMaxSize() - .drawBehind { - if (edgeColors != null) { - val brush = if (isVerticalGaps) { - Brush.verticalGradient(listOf(animatedTop, animatedBottom)) - } else { - Brush.horizontalGradient(listOf(animatedTop, animatedBottom)) + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { + if (edgeSampling != null) { + if (edgeSampling.vertical) { + val containerHeight = size.height.toInt() + val imageTop = if (imageSize != null) ((containerHeight - imageSize.height) / 2).coerceAtLeast(0).toFloat() + else size.height / 2 + val imageBottom = if (imageSize != null) (imageTop + imageSize.height).coerceAtMost(size.height) + else size.height / 2 + + // Top bloom + drawRect( + brush = Brush.verticalGradient( + 0f to backgroundColor, + 1f to animatedTop, + startY = 0f, + endY = imageTop + 1f // Overlap by 1px + ), + topLeft = Offset.Zero, + size = Size(size.width, imageTop + 1f) + ) + + // Bottom bloom + drawRect( + brush = Brush.verticalGradient( + 0f to animatedBottom, + 1f to backgroundColor, + startY = imageBottom - 1f, // Overlap by 1px + endY = size.height + ), + topLeft = Offset(0f, imageBottom - 1f), + size = Size(size.width, size.height - imageBottom + 1f) + ) + } else { + val containerWidth = size.width.toInt() + val imageLeft = if (imageSize != null) ((containerWidth - imageSize.width) / 2).coerceAtLeast(0).toFloat() + else size.width / 2 + val imageRight = if (imageSize != null) (imageLeft + imageSize.width).coerceAtMost(size.width) + else size.width / 2 + + // Left bloom + drawRect( + brush = Brush.horizontalGradient( + 0f to backgroundColor, + 1f to animatedLeft, + startX = 0f, + endX = imageLeft + 1f // Overlap by 1px + ), + topLeft = Offset.Zero, + size = Size(imageLeft + 1f, size.height) + ) + + // Right bloom + drawRect( + brush = Brush.horizontalGradient( + 0f to animatedRight, + 1f to backgroundColor, + startX = imageRight - 1f, // Overlap by 1px + endX = size.width + ), + topLeft = Offset(imageRight - 1f, 0f), + size = Size(size.width - imageRight + 1f, size.height) + ) + } } - drawRect(brush) } - } - ) { + ) + { content() } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index 6153de7e..fb209750 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -195,22 +195,12 @@ fun BoxScope.PagedReaderContent( pageState.value ?: Page(meta, null) } - val edgeColors = if (adaptiveBackground && pages.size == 1) pages.first().edgeColors else null - val isVerticalGaps = remember(pages, currentContainerSize) { - val imageSize = pages.firstOrNull()?.imageResult?.let { - if (it is ReaderImageResult.Success) it.image.displaySize.value else null - } - if (imageSize == null || currentContainerSize.width == 0 || currentContainerSize.height == 0) true - else { - val containerRatio = currentContainerSize.width.toDouble() / currentContainerSize.height - val imageRatio = imageSize.width.toDouble() / imageSize.height - imageRatio < containerRatio - } - } + val edgeSampling = if (adaptiveBackground && pages.size == 1) pages.first().edgeSampling else null + val imageSize = if (pages.size == 1) pages.first().imageSize else null AdaptiveBackground( - edgeColors = edgeColors, - isVerticalGaps = isVerticalGaps, + edgeSampling = edgeSampling, + imageSize = imageSize, ) { when (layout) { SINGLE_PAGE -> pages.firstOrNull()?.let { SinglePageLayout(it) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index f09bd2ae..f17ed2f3 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -31,9 +31,10 @@ import kotlinx.coroutines.launch import snd.komelia.AppNotification import snd.komelia.AppNotifications import snd.komelia.image.BookImageLoader +import snd.komelia.image.EdgeSampling import snd.komelia.image.ReaderImage.PageId import snd.komelia.image.ReaderImageResult -import snd.komelia.image.getEdgeColors +import snd.komelia.image.getEdgeSampling import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.settings.ImageReaderSettingsRepository import snd.komelia.settings.model.LayoutScaleType @@ -377,10 +378,14 @@ class PagedReaderState( if (cached != null && !cached.isCancelled) cached else pageLoadScope.async { val imageResult = imageLoader.loadReaderImage(meta.bookId, meta.pageNumber) - val edgeColors = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { + val containerSize = screenScaleState.areaSize.value + val imageSize = if (imageResult is ReaderImageResult.Success) { + imageResult.image.calculateSizeForArea(containerSize, true) + } else null + + val edgeSampling = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { val originalImage = imageResult.image.getOriginalImage().getOrNull() if (originalImage != null) { - val containerSize = screenScaleState.areaSize.value val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true else { val containerRatio = containerSize.width.toDouble() / containerSize.height @@ -388,11 +393,10 @@ class PagedReaderState( imageRatio > containerRatio } - val colors = originalImage.getEdgeColors(isVerticalGaps) - colors + originalImage.getEdgeSampling(isVerticalGaps) } else null } else null - Page(meta, imageResult, edgeColors) + Page(meta, imageResult, edgeSampling, imageSize) }.also { imageCache.put(pageId, it) } } @@ -448,10 +452,14 @@ class PagedReaderState( } else { val job = pageLoadScope.async { val imageResult = imageLoader.loadReaderImage(page.bookId, page.pageNumber) - val edgeColors = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { + val containerSize = screenScaleState.areaSize.value + val imageSize = if (imageResult is ReaderImageResult.Success) { + imageResult.image.calculateSizeForArea(containerSize, true) + } else null + + val edgeSampling = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { val originalImage = imageResult.image.getOriginalImage().getOrNull() if (originalImage != null) { - val containerSize = screenScaleState.areaSize.value val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true else { val containerRatio = containerSize.width.toDouble() / containerSize.height @@ -459,10 +467,10 @@ class PagedReaderState( imageRatio > containerRatio } - originalImage.getEdgeColors(isVerticalGaps) + originalImage.getEdgeSampling(isVerticalGaps) } else null } else null - Page(page, imageResult, edgeColors) + Page(page, imageResult, edgeSampling, imageSize) }.also { imageCache.put(pageId, it) } job.await() } @@ -743,7 +751,8 @@ class PagedReaderState( data class Page( val metadata: PageMetadata, val imageResult: ReaderImageResult?, - val edgeColors: Pair? = null, + val edgeSampling: EdgeSampling? = null, + val imageSize: IntSize? = null, ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index a1e77a2b..a1e1c85f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -148,22 +148,12 @@ fun BoxScope.PanelsReaderContent( pageState.value = panelsReaderState.getPage(pageMeta) } - val edgeColors = if (adaptiveBackground) pageState.value?.edgeColors else null - val isVerticalGaps = remember(pageState.value, currentContainerSize) { - val imageSize = pageState.value?.imageResult?.let { - if (it is ReaderImageResult.Success) it.image.displaySize.value else null - } - if (imageSize == null || currentContainerSize.width == 0 || currentContainerSize.height == 0) true - else { - val containerRatio = currentContainerSize.width.toDouble() / currentContainerSize.height - val imageRatio = imageSize.width.toDouble() / imageSize.height - imageRatio < containerRatio - } - } + val edgeSampling = if (adaptiveBackground) pageState.value?.edgeSampling else null + val imageSize = pageState.value?.imageSize AdaptiveBackground( - edgeColors = edgeColors, - isVerticalGaps = isVerticalGaps, + edgeSampling = edgeSampling, + imageSize = imageSize, ) { Box( modifier = Modifier.fillMaxSize(), diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index 45fdce6e..feb9cd26 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -33,11 +33,12 @@ import kotlinx.coroutines.launch import snd.komelia.AppNotification import snd.komelia.AppNotifications import snd.komelia.image.BookImageLoader +import snd.komelia.image.EdgeSampling import snd.komelia.image.ImageRect import snd.komelia.image.KomeliaPanelDetector import snd.komelia.image.ReaderImage.PageId import snd.komelia.image.ReaderImageResult -import snd.komelia.image.getEdgeColors +import snd.komelia.image.getEdgeSampling import snd.komelia.onnxruntime.OnnxRuntimeException import snd.komelia.settings.ImageReaderSettingsRepository import snd.komelia.settings.model.PagedReadingDirection @@ -433,12 +434,14 @@ class PanelsReaderState( metadata = pageMeta, imageResult = null, panelData = null, - edgeColors = null + edgeSampling = null, + imageSize = null ) ?: PanelsPage( metadata = pageMeta, imageResult = null, panelData = null, - edgeColors = null + edgeSampling = null, + imageSize = null ) } currentPageIndex.update { PageIndex(pageIndex, 0) } @@ -535,16 +538,17 @@ class PanelsReaderState( panelData = null ) - val imageSize = IntSize(originalImage.width, originalImage.height) - val edgeColors = if (adaptiveBackground.value) { - val containerSize = screenScaleState.areaSize.value + val containerSize = screenScaleState.areaSize.value + val fitToScreenSize = image.calculateSizeForArea(containerSize, true) + val originalImageSize = IntSize(originalImage.width, originalImage.height) + val edgeSampling = if (adaptiveBackground.value) { val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true else { val containerRatio = containerSize.width.toDouble() / containerSize.height - val imageRatio = imageSize.width.toDouble() / imageSize.height + val imageRatio = originalImageSize.width.toDouble() / originalImageSize.height imageRatio > containerRatio } - originalImage.getEdgeColors(isVerticalGaps) + originalImage.getEdgeSampling(isVerticalGaps) } else null val (panels, duration) = measureTimedValue { @@ -563,7 +567,7 @@ class PanelsReaderState( val panelData = PanelData( panels = panels, - originalImageSize = imageSize, + originalImageSize = originalImageSize, panelCoversMajorityOfImage = false // Placeholder for Phase 2 ) @@ -571,7 +575,8 @@ class PanelsReaderState( metadata = meta, imageResult = imageResult, panelData = panelData, - edgeColors = edgeColors + edgeSampling = edgeSampling, + imageSize = fitToScreenSize ) } imageCache.put(pageId, loadJob) @@ -675,7 +680,8 @@ class PanelsReaderState( val metadata: PageMetadata, val imageResult: ReaderImageResult?, val panelData: PanelData?, - val edgeColors: Pair? = null, + val edgeSampling: EdgeSampling? = null, + val imageSize: IntSize? = null, ) data class PanelData( From 0f0dc05d627a81e62c36d994ad2eeb5e2c0ae128 Mon Sep 17 00:00:00 2001 From: eserero Date: Sun, 1 Mar 2026 18:10:29 +0200 Subject: [PATCH 26/35] feat(reader): implement four-side sampling and corner blending for adaptive background This commit completes Phase 3 of the adaptive background implementation, optimizing for Panel Mode: - Update EdgeSampling to support all four sides (top, bottom, left, right). - Update ReaderImageUtils to sample all four edges and provide 1D color lines. - Implement trapezoidal gradient zones with 45-degree corner mitering in AdaptiveBackground. - Move AdaptiveBackground outside of the ScalableContainer in Panel Mode for static rendering. - Update PanelsReaderContent to calculate real-time imageBounds for background alignment. --- .../kotlin/snd/komelia/image/KomeliaImage.kt | 7 +- .../snd/komelia/image/ReaderImageUtils.kt | 65 +++---- .../reader/image/common/AdaptiveBackground.kt | 173 ++++++++++-------- .../reader/image/paged/PagedReaderContent.kt | 14 +- .../ui/reader/image/paged/PagedReaderState.kt | 18 +- .../image/panels/PanelsReaderContent.kt | 88 +++++---- .../reader/image/panels/PanelsReaderState.kt | 8 +- 7 files changed, 190 insertions(+), 183 deletions(-) diff --git a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt index e56ed3e5..03432f17 100644 --- a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt +++ b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt @@ -65,9 +65,10 @@ enum class ReduceKernel { } data class EdgeSampling( - val vertical: Boolean, - val first: EdgeSample, - val second: EdgeSample, + val top: EdgeSample? = null, + val bottom: EdgeSample? = null, + val left: EdgeSample? = null, + val right: EdgeSample? = null, ) data class EdgeSample( diff --git a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt index c45d1087..7a3b29bc 100644 --- a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt +++ b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt @@ -60,50 +60,29 @@ suspend fun KomeliaImage.getEdgeColorLines(vertical: Boolean): Pair Unit ) { val backgroundColor = MaterialTheme.colorScheme.background - val topColor = remember(edgeSampling) { - if (edgeSampling?.vertical == true) Color(edgeSampling.first.averageColor) else Color.Transparent - } - val bottomColor = remember(edgeSampling) { - if (edgeSampling?.vertical == true) Color(edgeSampling.second.averageColor) else Color.Transparent - } - val leftColor = remember(edgeSampling) { - if (edgeSampling?.vertical == false) Color(edgeSampling.first.averageColor) else Color.Transparent - } - val rightColor = remember(edgeSampling) { - if (edgeSampling?.vertical == false) Color(edgeSampling.second.averageColor) else Color.Transparent - } + val topColor = remember(edgeSampling) { edgeSampling?.top?.averageColor?.let { Color(it) } ?: Color.Transparent } + val bottomColor = remember(edgeSampling) { edgeSampling?.bottom?.averageColor?.let { Color(it) } ?: Color.Transparent } + val leftColor = remember(edgeSampling) { edgeSampling?.left?.averageColor?.let { Color(it) } ?: Color.Transparent } + val rightColor = remember(edgeSampling) { edgeSampling?.right?.averageColor?.let { Color(it) } ?: Color.Transparent } val animatedTop by animateColorAsState(targetValue = topColor, animationSpec = tween(durationMillis = 500)) val animatedBottom by animateColorAsState(targetValue = bottomColor, animationSpec = tween(durationMillis = 500)) val animatedLeft by animateColorAsState(targetValue = leftColor, animationSpec = tween(durationMillis = 500)) val animatedRight by animateColorAsState(targetValue = rightColor, animationSpec = tween(durationMillis = 500)) - Box( - modifier = modifier - .fillMaxSize() - .drawBehind { - if (edgeSampling != null) { - if (edgeSampling.vertical) { - val containerHeight = size.height.toInt() - val imageTop = if (imageSize != null) ((containerHeight - imageSize.height) / 2).coerceAtLeast(0).toFloat() - else size.height / 2 - val imageBottom = if (imageSize != null) (imageTop + imageSize.height).coerceAtMost(size.height) - else size.height / 2 - - // Top bloom - drawRect( - brush = Brush.verticalGradient( - 0f to backgroundColor, - 1f to animatedTop, - startY = 0f, - endY = imageTop + 1f // Overlap by 1px - ), - topLeft = Offset.Zero, - size = Size(size.width, imageTop + 1f) - ) - - // Bottom bloom - drawRect( - brush = Brush.verticalGradient( - 0f to animatedBottom, - 1f to backgroundColor, - startY = imageBottom - 1f, // Overlap by 1px - endY = size.height - ), - topLeft = Offset(0f, imageBottom - 1f), - size = Size(size.width, size.height - imageBottom + 1f) + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { + if (edgeSampling != null) { + val containerWidth = size.width + val containerHeight = size.height + + val imageLeft = imageBounds?.left ?: 0f + val imageTop = imageBounds?.top ?: 0f + val imageRight = imageBounds?.right ?: containerWidth + val imageBottom = imageBounds?.bottom ?: containerHeight + + // Top Zone + if (imageTop > 0) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(containerWidth, 0f) + lineTo(imageRight, imageTop) + lineTo(imageLeft, imageTop) + close() + } + drawPath( + path = path, + brush = Brush.verticalGradient( + 0f to backgroundColor, + 1f to animatedTop, + startY = 0f, + endY = imageTop ) - } else { - val containerWidth = size.width.toInt() - val imageLeft = if (imageSize != null) ((containerWidth - imageSize.width) / 2).coerceAtLeast(0).toFloat() - else size.width / 2 - val imageRight = if (imageSize != null) (imageLeft + imageSize.width).coerceAtMost(size.width) - else size.width / 2 - - // Left bloom - drawRect( - brush = Brush.horizontalGradient( - 0f to backgroundColor, - 1f to animatedLeft, - startX = 0f, - endX = imageLeft + 1f // Overlap by 1px - ), - topLeft = Offset.Zero, - size = Size(imageLeft + 1f, size.height) + ) + } + + // Bottom Zone + if (imageBottom < containerHeight) { + val path = Path().apply { + moveTo(0f, containerHeight) + lineTo(containerWidth, containerHeight) + lineTo(imageRight, imageBottom) + lineTo(imageLeft, imageBottom) + close() + } + drawPath( + path = path, + brush = Brush.verticalGradient( + 0f to animatedBottom, + 1f to backgroundColor, + startY = imageBottom, + endY = containerHeight ) - - // Right bloom - drawRect( - brush = Brush.horizontalGradient( - 0f to animatedRight, - 1f to backgroundColor, - startX = imageRight - 1f, // Overlap by 1px - endX = size.width - ), - topLeft = Offset(imageRight - 1f, 0f), - size = Size(size.width - imageRight + 1f, size.height) + ) + } + + // Left Zone + if (imageLeft > 0) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(imageLeft, imageTop) + lineTo(imageLeft, imageBottom) + lineTo(0f, containerHeight) + close() + } + drawPath( + path = path, + brush = Brush.horizontalGradient( + 0f to backgroundColor, + 1f to animatedLeft, + startX = 0f, + endX = imageLeft ) + ) + } + + // Right Zone + if (imageRight < containerWidth) { + val path = Path().apply { + moveTo(containerWidth, 0f) + lineTo(imageRight, imageTop) + lineTo(imageRight, imageBottom) + lineTo(containerWidth, containerHeight) + close() } + drawPath( + path = path, + brush = Brush.horizontalGradient( + 0f to animatedRight, + 1f to backgroundColor, + startX = imageRight, + endX = containerWidth + ) + ) } } - ) - { + } + ) { content() } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index fb209750..55d72a83 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -55,6 +55,10 @@ import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookSta import snd.komelia.ui.reader.image.common.AdaptiveBackground import kotlin.math.abs +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.toSize + @Composable fun BoxScope.PagedReaderContent( showHelpDialog: Boolean, @@ -197,10 +201,18 @@ fun BoxScope.PagedReaderContent( val edgeSampling = if (adaptiveBackground && pages.size == 1) pages.first().edgeSampling else null val imageSize = if (pages.size == 1) pages.first().imageSize else null + val imageBounds = remember(imageSize, currentContainerSize) { + if (imageSize == null) null + else { + val left = (currentContainerSize.width - imageSize.width) / 2f + val top = (currentContainerSize.height - imageSize.height) / 2f + Rect(Offset(left, top), imageSize.toSize()) + } + } AdaptiveBackground( edgeSampling = edgeSampling, - imageSize = imageSize, + imageBounds = imageBounds, ) { when (layout) { SINGLE_PAGE -> pages.firstOrNull()?.let { SinglePageLayout(it) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index f17ed2f3..1455c962 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -386,14 +386,7 @@ class PagedReaderState( val edgeSampling = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { val originalImage = imageResult.image.getOriginalImage().getOrNull() if (originalImage != null) { - val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true - else { - val containerRatio = containerSize.width.toDouble() / containerSize.height - val imageRatio = originalImage.width.toDouble() / originalImage.height - imageRatio > containerRatio - } - - originalImage.getEdgeSampling(isVerticalGaps) + originalImage.getEdgeSampling() } else null } else null Page(meta, imageResult, edgeSampling, imageSize) @@ -460,14 +453,7 @@ class PagedReaderState( val edgeSampling = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { val originalImage = imageResult.image.getOriginalImage().getOrNull() if (originalImage != null) { - val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true - else { - val containerRatio = containerSize.width.toDouble() / containerSize.height - val imageRatio = originalImage.width.toDouble() / originalImage.height - imageRatio > containerRatio - } - - originalImage.getEdgeSampling(isVerticalGaps) + originalImage.getEdgeSampling() } else null } else null Page(page, imageResult, edgeSampling, imageSize) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index a1e1c85f..ba399cdf 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -46,6 +46,10 @@ import snd.komelia.ui.reader.image.paged.PagedReaderState.PageNavigationEvent import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize import snd.komelia.ui.reader.image.common.AdaptiveBackground @Composable @@ -107,32 +111,51 @@ fun BoxScope.PanelsReaderContent( } val coroutineScope = rememberCoroutineScope() - ReaderControlsOverlay( - readingDirection = layoutDirection, - onNexPageClick = panelsReaderState::nextPanel, - onPrevPageClick = panelsReaderState::previousPanel, - contentAreaSize = currentContainerSize, - scaleState = screenScaleState, - tapToZoom = tapToZoom, - isSettingsMenuOpen = showSettingsMenu, - onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, - modifier = Modifier.onKeyEvent { event -> - pagedReaderOnKeyEvents( - event = event, - readingDirection = readingDirection, - onReadingDirectionChange = panelsReaderState::onReadingDirectionChange, - onMoveToNextPage = { coroutineScope.launch { panelsReaderState.nextPanel() } }, - onMoveToPrevPage = { coroutineScope.launch { panelsReaderState.previousPanel() } }, - volumeKeysNavigation = volumeKeysNavigation - ) + val currentPage = panelsReaderState.currentPage.collectAsState().value + val edgeSampling = if (adaptiveBackground) currentPage?.edgeSampling else null + val transforms = screenScaleState.transformation.collectAsState().value + val targetSize = screenScaleState.targetSize.collectAsState().value + val imageBounds = remember(transforms, targetSize, currentContainerSize) { + if (targetSize == Size.Zero || currentContainerSize == IntSize.Zero) null + else { + val width = targetSize.width * transforms.scale + val height = targetSize.height * transforms.scale + val left = (currentContainerSize.width / 2f) - (width / 2f) + transforms.offset.x + val top = (currentContainerSize.height / 2f) - (height / 2f) + transforms.offset.y + Rect(left, top, left + width, top + height) } + } + + AdaptiveBackground( + edgeSampling = edgeSampling, + imageBounds = imageBounds, ) { - ScalableContainer(scaleState = screenScaleState) { - val transitionPage = panelsReaderState.transitionPage.collectAsState().value - if (transitionPage != null) { - TransitionPage(transitionPage) - } else { - if (metadata.isNotEmpty()) { + ReaderControlsOverlay( + readingDirection = layoutDirection, + onNexPageClick = panelsReaderState::nextPanel, + onPrevPageClick = panelsReaderState::previousPanel, + contentAreaSize = currentContainerSize, + scaleState = screenScaleState, + tapToZoom = tapToZoom, + isSettingsMenuOpen = showSettingsMenu, + onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, + modifier = Modifier.onKeyEvent { event -> + pagedReaderOnKeyEvents( + event = event, + readingDirection = readingDirection, + onReadingDirectionChange = panelsReaderState::onReadingDirectionChange, + onMoveToNextPage = { coroutineScope.launch { panelsReaderState.nextPanel() } }, + onMoveToPrevPage = { coroutineScope.launch { panelsReaderState.previousPanel() } }, + volumeKeysNavigation = volumeKeysNavigation + ) + } + ) { + ScalableContainer(scaleState = screenScaleState) { + val transitionPage = panelsReaderState.transitionPage.collectAsState().value + if (transitionPage != null) { + TransitionPage(transitionPage) + } else { + if (metadata.isNotEmpty()) { HorizontalPager( state = pagerState, userScrollEnabled = false, @@ -148,21 +171,14 @@ fun BoxScope.PanelsReaderContent( pageState.value = panelsReaderState.getPage(pageMeta) } - val edgeSampling = if (adaptiveBackground) pageState.value?.edgeSampling else null - val imageSize = pageState.value?.imageSize - - AdaptiveBackground( - edgeSampling = edgeSampling, - imageSize = imageSize, + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - ReaderImageContent(pageState.value?.imageResult) - } + ReaderImageContent(pageState.value?.imageResult) } } + } } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index feb9cd26..8dffbc37 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -542,13 +542,7 @@ class PanelsReaderState( val fitToScreenSize = image.calculateSizeForArea(containerSize, true) val originalImageSize = IntSize(originalImage.width, originalImage.height) val edgeSampling = if (adaptiveBackground.value) { - val isVerticalGaps = if (containerSize.width == 0 || containerSize.height == 0) true - else { - val containerRatio = containerSize.width.toDouble() / containerSize.height - val imageRatio = originalImageSize.width.toDouble() / originalImageSize.height - imageRatio > containerRatio - } - originalImage.getEdgeSampling(isVerticalGaps) + originalImage.getEdgeSampling() } else null val (panels, duration) = measureTimedValue { From 97a8ec6b94398f83885ff86bbaf7eed6b752325a Mon Sep 17 00:00:00 2001 From: eserero Date: Sun, 1 Mar 2026 20:14:28 +0200 Subject: [PATCH 27/35] fix(reader): prevent image loading getting stuck on page change Ongoing image downloads were being cancelled when changing pages due to aggressive cancelChildren() on the page load scope. Introduced targeted cancellation of the spread/page loading job while allowing individual image loading tasks to continue. --- .../komelia/ui/reader/image/paged/PagedReaderState.kt | 10 ++++++---- .../ui/reader/image/panels/PanelsReaderState.kt | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index 1455c962..6a980fb2 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -69,6 +69,7 @@ class PagedReaderState( ) { private val stateScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val pageLoadScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var loadSpreadJob: kotlinx.coroutines.Job? = null private val imageCache = Cache.Builder>() .maximumCacheSize(10) @@ -146,6 +147,7 @@ class PagedReaderState( fun stop() { stateScope.coroutineContext.cancelChildren() + pageLoadScope.coroutineContext.cancelChildren() screenScaleState.enableOverscrollArea(false) screenScaleState.edgeHandoffEnabled = false imageCache.invalidateAll() @@ -316,8 +318,8 @@ class PagedReaderState( currentSpreadIndex.value = page pageNavigationEvents.tryEmit(PageNavigationEvent.Immediate(page)) - pageLoadScope.coroutineContext.cancelChildren() - pageLoadScope.launch { loadSpread(page) } + loadSpreadJob?.cancel() + loadSpreadJob = pageLoadScope.launch { loadSpread(page) } } private fun loadPage(spreadIndex: Int) { @@ -328,8 +330,8 @@ class PagedReaderState( pageNavigationEvents.tryEmit(PageNavigationEvent.Animated(spreadIndex)) } - pageLoadScope.coroutineContext.cancelChildren() - pageLoadScope.launch { loadSpread(spreadIndex) } + loadSpreadJob?.cancel() + loadSpreadJob = pageLoadScope.launch { loadSpread(spreadIndex) } } sealed interface PageNavigationEvent { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index 8dffbc37..7effc555 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -75,6 +75,7 @@ class PanelsReaderState( ) { private val stateScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val pageLoadScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var pageLoadJob: kotlinx.coroutines.Job? = null private val imageCache = Cache.Builder>() .maximumCacheSize(10) .eventListener { @@ -178,6 +179,7 @@ class PanelsReaderState( fun stop() { stateScope.coroutineContext.cancelChildren() + pageLoadScope.coroutineContext.cancelChildren() screenScaleState.enableOverscrollArea(false) imageCache.invalidateAll() } @@ -419,8 +421,8 @@ class PanelsReaderState( stateScope.launch { readerState.onProgressChange(pageNumber) } } - pageLoadScope.coroutineContext.cancelChildren() - pageLoadScope.launch { doPageLoad(pageIndex, startAtLast, isAnimated) } + pageLoadJob?.cancel() + pageLoadJob = pageLoadScope.launch { doPageLoad(pageIndex, startAtLast, isAnimated) } } private suspend fun doPageLoad(pageIndex: Int, startAtLast: Boolean = false, isAnimated: Boolean = false) { From f1b5d9da10239910a12b3f678635cdd342bc6ed3 Mon Sep 17 00:00:00 2001 From: eserero Date: Sun, 1 Mar 2026 21:45:18 +0200 Subject: [PATCH 28/35] fix(ui): eliminate double animation and cover flash on immersive book open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold the pager at 1 page (always showing the tapped book) until the shared-element transition settles before expanding to the full sibling count. This prevents two race-condition bugs that were deterministic per (book, navigation-source) pair: - Bug A (cover flash): siblingBooks loading mid-transition caused page 0 to show siblingBooks[0] (a different book) because the shared-element cover key changed mid-flight. - Bug B (double card slide): new pages entering composition while AnimatedVisibility was still "entering" fired a second slideInVertically on ImmersiveDetailScaffold. Key changes in ImmersiveBookContent: - pagerExpanded / initialScrollDone are now separate flags: pager expands first (so scrollToPage can run), then initialScrollDone flips only after the pager has settled on the correct page — preventing the brief window where page 0 shows the wrong cover. - selectedBook is guarded by initialScrollDone so onBookChange is not called with the wrong sibling before the pager is initialised, which previously propagated back as a changed `book` prop and corrupted the pageBook guard. - transitionIsSettled (derivedStateOf over animatedVisibilityScope) gates the scroll so no new pages enter composition during the enter transition. Co-Authored-By: Claude Sonnet 4.6 --- .../ui/book/immersive/ImmersiveBookContent.kt | 52 ++- .../ui/common/images/ThumbnailImage.kt | 5 +- .../immersive/ImmersiveDetailScaffold.kt | 165 ++++--- .../snd/komelia/ui/oneshot/OneshotScreen.kt | 8 +- .../immersive/ImmersiveOneshotContent.kt | 439 ++++++++++-------- .../immersive/ImmersiveSeriesContent.kt | 20 +- 6 files changed, 412 insertions(+), 277 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt index 697b14ed..532bb76d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -1,5 +1,6 @@ package snd.komelia.ui.book.immersive +import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween @@ -41,8 +42,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -99,29 +103,48 @@ fun ImmersiveBookContent( initiallyExpanded: Boolean, onExpandChange: (Boolean) -> Unit, ) { - val initialPage = remember(siblingBooks, book) { - siblingBooks.indexOfFirst { it.id == book.id }.coerceAtLeast(0) + // Detect when the shared transition is no longer "entering". + // animatedVisibilityScope is null when there is no shared transition (fallback: treat as settled). + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + val transitionIsSettled = remember(animatedVisibilityScope) { + derivedStateOf { + animatedVisibilityScope == null || + animatedVisibilityScope.transition.currentState == EnterExitState.Visible + } } - val pagerState = rememberPagerState( - initialPage = initialPage, - pageCount = { maxOf(1, siblingBooks.size) } - ) - // Once siblings load, jump to the correct page without animation. - // Guard with initialScrollDone so that subsequent siblingBooks emissions - // (e.g. from read-progress updates) don't yank the pager back. + // pagerExpanded: controls page count (1 → N). Flipped first so the pager can be scrolled. + // initialScrollDone: controls the pageBook guard. Flipped AFTER scrollToPage so that no + // page shows the wrong cover during the brief window between expansion and scroll. + var pagerExpanded by remember { mutableStateOf(false) } var initialScrollDone by remember { mutableStateOf(false) } + val pagerPageCount = if (pagerExpanded) maxOf(1, siblingBooks.size) else 1 + val pagerState = rememberPagerState(pageCount = { pagerPageCount }) + LaunchedEffect(siblingBooks) { if (!initialScrollDone && siblingBooks.isNotEmpty()) { + // Wait for the transition to settle so new pages don't fire animateEnterExit. + snapshotFlow { transitionIsSettled.value }.first { it } val idx = siblingBooks.indexOfFirst { it.id == book.id }.coerceAtLeast(0) + // Expand pager (pageBook guard still holds — all pages show book's cover). + pagerExpanded = true + // Wait for the pager to recognise the expanded page count, then snap to correct page. + snapshotFlow { pagerState.pageCount }.first { it > idx } pagerState.scrollToPage(idx) + // Only now unlock pageBook — pager is already on the right page, so siblingBooks[idx] + // is the same book and coverData key is unchanged → no flash. initialScrollDone = true } } - // selectedBook drives the FAB and 3-dot menu after each swipe settles - val selectedBook = remember(pagerState.settledPage, siblingBooks) { - siblingBooks.getOrNull(pagerState.settledPage) ?: book + // selectedBook drives the FAB and 3-dot menu after each swipe settles. + // Guard by initialScrollDone: before the pager has landed on the right page, always return + // the originally-tapped book. Without this guard, settledPage=0 when siblings first load + // would produce selectedBook=siblingBooks[0], triggering onBookChange with the wrong book — + // which propagates back as the `book` prop and corrupts the pageBook guard above. + val selectedBook = remember(pagerState.settledPage, siblingBooks, initialScrollDone) { + if (initialScrollDone) siblingBooks.getOrNull(pagerState.settledPage) ?: book + else book } LaunchedEffect(selectedBook) { @@ -131,7 +154,6 @@ fun ImmersiveBookContent( var showDownloadConfirmationDialog by remember { mutableStateOf(false) } val sharedTransitionScope = LocalSharedTransitionScope.current - val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val fabOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { with(sharedTransitionScope) { @@ -164,7 +186,9 @@ fun ImmersiveBookContent( modifier = Modifier.fillMaxSize(), pageSpacing = 8.dp, ) { pageIndex -> - val pageBook = siblingBooks.getOrNull(pageIndex) ?: book + // During the transition (initialScrollDone = false, pager has 1 page) always show the + // tapped book so the shared-element cover key stays stable throughout the animation. + val pageBook = if (!initialScrollDone) book else (siblingBooks.getOrNull(pageIndex) ?: book) // Memoize to avoid a new Random requestCache on every recomposition, which would // cause ThumbnailImage's remember(data,cacheKey) to rebuild the ImageRequest and flash. val coverData = remember(pageBook.id) { BookDefaultThumbnailRequest(pageBook.id) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt index 15b0cf94..67e667ed 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt @@ -20,15 +20,16 @@ fun ThumbnailImage( cacheKey: String, contentScale: ContentScale = ContentScale.Fit, crossfade: Boolean = true, + usePlaceholderKey: Boolean = true, placeholder: Painter? = NoopPainter, modifier: Modifier = Modifier, ) { val context = LocalPlatformContext.current - val request = remember(data, cacheKey) { + val request = remember(data, cacheKey, crossfade, usePlaceholderKey) { ImageRequest.Builder(context) .data(data) .memoryCacheKey(cacheKey) - .placeholderMemoryCacheKey(cacheKey) + .apply { if (usePlaceholderKey) placeholderMemoryCacheKey(cacheKey) } .diskCacheKey(cacheKey) .precision(Precision.INEXACT) .crossfade(crossfade) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index 39b2bbe0..e8631c14 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -1,6 +1,7 @@ package snd.komelia.ui.common.immersive import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationVector @@ -11,6 +12,7 @@ import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -117,24 +119,52 @@ fun ImmersiveDetailScaffold( val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current - // The entire scaffold is the shared element — it morphs from the source thumbnail's bounds - // to full-screen. Applied to an inner Box (not BoxWithConstraints) so the outer - // BoxWithConstraints always measures at real screen dimensions, keeping collapsedOffset - // correct throughout the animation. - val scaffoldSharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + // Whether a shared transition is in progress — used to suppress crossfade during animation. + val inSharedTransition = sharedTransitionScope != null && animatedVisibilityScope != null + + // Cover image is the shared element — flies from source thumbnail to its destination position. + // Must be computed outside BoxWithConstraints for composition-phase matching. + val coverSharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { with(sharedTransitionScope) { Modifier.sharedBounds( rememberSharedContentState(key = "cover-$coverKey"), animatedVisibilityScope = animatedVisibilityScope, enter = EnterTransition.None, - exit = fadeOut(tween(200)), + exit = ExitTransition.None, boundsTransform = { _, _ -> tween(durationMillis = 600, easing = emphasizedEasing) }, ) } } else Modifier - // Whether a shared transition is in progress — used to suppress crossfade during animation. - val inSharedTransition = sharedTransitionScope != null && animatedVisibilityScope != null + // Scaffold fades in behind the flying cover image and sliding card. + // No slide here — the card handles its own slide via cardOverlayModifier. A slide on + // BoxWithConstraints would move the layout positions of all children, causing the card to + // double-animate and causing the cover sharedBounds destination to shift mid-transition. + val scaffoldEnterExitModifier = if (animatedVisibilityScope != null) { + with(animatedVisibilityScope) { + Modifier.animateEnterExit( + enter = fadeIn(tween(300)), + exit = fadeOut(tween(200, easing = emphasizedAccelerateEasing)) + ) + } + } else Modifier + + // Card enters in the SharedTransition overlay at z=0.5 — always above the cover image (z=0 + // from sharedBounds default). This prevents the z-flip where the source thumbnail image + // would otherwise appear on top of the card during the container-transform crossfade. + val cardOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 0.5f) + .animateEnterExit( + enter = slideInVertically(tween(500, easing = emphasizedEasing)) { it / 4 } + + fadeIn(tween(300)), + exit = fadeOut(tween(200, easing = emphasizedAccelerateEasing)) + ) + } + } + } else Modifier val uiEnterExitModifier = if (animatedVisibilityScope != null) { with(animatedVisibilityScope) { @@ -159,10 +189,7 @@ fun ImmersiveDetailScaffold( } } else Modifier - // Outer BoxWithConstraints is NOT under sharedBounds, so maxHeight always equals the real - // screen height — never the thumbnail size mid-morph. All layout values (collapsedOffset, - // card height, cover height) are derived from this real measurement. - BoxWithConstraints(modifier = modifier.fillMaxSize()) { + BoxWithConstraints(modifier = modifier.fillMaxSize().then(scaffoldEnterExitModifier)) { val screenHeight = maxHeight val collapsedOffset = screenHeight * 0.65f val collapsedOffsetPx = with(density) { collapsedOffset.toPx() } @@ -262,71 +289,73 @@ fun ImmersiveDetailScaffold( val statusBarDp = LocalRawStatusBarHeight.current val statusBarPx = with(density) { statusBarDp.toPx() } - // Inner Box carries sharedBounds — it morphs from thumbnail size to full-screen. - // Because layout values come from the outer BoxWithConstraints, the cover image and card - // are always sized/positioned for the full screen, with the morphing clip revealing them - // naturally as the bounds expand. Image and card z-order never flip. - Box(modifier = Modifier.fillMaxSize().then(scaffoldSharedModifier)) { - Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { - // Layer 1: Cover image — fades out as card expands - // Extends by the card corner radius so it fills behind the rounded corners - // When immersive=true, shifts up behind the status bar - ThumbnailImage( - data = coverData, - cacheKey = coverKey, - crossfade = !inSharedTransition, - contentScale = ContentScale.Crop, - modifier = if (immersive) - Modifier - .fillMaxWidth() - .offset { IntOffset(0, -statusBarPx.roundToInt()) } - .height(collapsedOffset + topCornerRadiusDp + statusBarDp) - .graphicsLayer { alpha = 1f - expandFraction } - else - Modifier - .fillMaxWidth() - .height(collapsedOffset + topCornerRadiusDp) - .graphicsLayer { alpha = 1f - expandFraction } - ) - - // Layer 2: Card - val cardShape = RoundedCornerShape(topStart = topCornerRadiusDp, topEnd = topCornerRadiusDp) - Column( - modifier = Modifier - .offset { IntOffset(0, cardOffsetPx.roundToInt()) } + // Layer 1: Cover image — shared element that flies from source thumbnail. + // coverSharedModifier placed first so sharedBounds captures the element at its + // final layout position; geometry modifiers then refine size/offset within that. + // crossfade suppressed during the transition to avoid the placeholder→loaded flash. + ThumbnailImage( + data = coverData, + cacheKey = coverKey, + crossfade = !inSharedTransition, + usePlaceholderKey = false, + contentScale = ContentScale.Crop, + modifier = if (immersive) + Modifier + .then(coverSharedModifier) + .fillMaxWidth() + .offset { IntOffset(0, -statusBarPx.roundToInt()) } + .height(collapsedOffset + topCornerRadiusDp + statusBarDp) + .graphicsLayer { alpha = 1f - expandFraction } + else + Modifier + .then(coverSharedModifier) .fillMaxWidth() - .height(screenHeight) - .nestedScroll(nestedScrollConnection) - .anchoredDraggable(state, Orientation.Vertical) - .shadow(elevation = 6.dp, shape = cardShape) - .clip(cardShape) - .background(backgroundColor) + .height(collapsedOffset + topCornerRadiusDp) + .graphicsLayer { alpha = 1f - expandFraction } + ) + + // Layer 2: Card — rendered in the SharedTransition overlay at z=0.5, above the + // cover image (z=0 from sharedBounds default). This ensures the card is always + // above the cover during the transition; after the transition, both return to + // normal layout where card (drawn later) is naturally above the cover. + val cardShape = RoundedCornerShape(topStart = topCornerRadiusDp, topEnd = topCornerRadiusDp) + Column( + modifier = Modifier + .then(cardOverlayModifier) + .offset { IntOffset(0, cardOffsetPx.roundToInt()) } + .fillMaxWidth() + .height(screenHeight) + .nestedScroll(nestedScrollConnection) + .anchoredDraggable(state, Orientation.Vertical) + .shadow(elevation = 6.dp, shape = cardShape) + .clip(cardShape) + .background(backgroundColor) + ) { + Box( + modifier = Modifier.fillMaxWidth().height(28.dp), + contentAlignment = Alignment.Center ) { Box( - modifier = Modifier.fillMaxWidth().height(28.dp), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(width = 32.dp, height = 4.dp) - .clip(RoundedCornerShape(2.dp)) - .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) - ) - } - Column(modifier = Modifier.fillMaxWidth().weight(1f)) { - cardContent(expandFraction) - } + modifier = Modifier + .size(width = 32.dp, height = 4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + ) } - - // Layer 5: Top bar - Box(modifier = Modifier.fillMaxWidth().then(uiEnterExitModifier).statusBarsPadding()) { - topBarContent() + Column(modifier = Modifier.fillMaxWidth().weight(1f)) { + cardContent(expandFraction) } } + + // Layer 3: Top bar + Box(modifier = Modifier.fillMaxWidth().then(uiEnterExitModifier).statusBarsPadding()) { + topBarContent() + } } - // Layer 4: FAB — outside shared bounds so it is never clipped by the morphing container + // Layer 4: FAB — in overlay at z=1, above everything Box( modifier = Modifier .align(Alignment.BottomCenter) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt index 6092a25f..13b53437 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt @@ -82,7 +82,7 @@ class OneshotScreen( val vmBook = vm.book.collectAsState().value val vmSeries = vm.series.collectAsState().value val vmLibrary = vm.library.collectAsState().value - if (platform == PlatformType.MOBILE && useNewUI && vmBook != null && vmSeries != null && vmLibrary != null) { + if (platform == PlatformType.MOBILE && useNewUI && vmSeries != null) { ImmersiveOneshotContent( series = vmSeries, book = vmBook, @@ -90,9 +90,10 @@ class OneshotScreen( accentColor = LocalAccentColor.current, onLibraryClick = { navigator.push(LibraryScreen(it.id)) }, onBookReadClick = { markReadProgress -> + val currentBook = vm.book.value ?: return@ImmersiveOneshotContent navigator.parent?.push( readerScreen( - book = vmBook, + book = currentBook, markReadProgress = markReadProgress, bookSiblingsContext = bookSiblingsContext, ) @@ -111,9 +112,10 @@ class OneshotScreen( ) }, onFilterClick = { filter -> + val libraryId = vm.book.value?.libraryId ?: return@ImmersiveOneshotContent navigator.popUntilRoot() navigator.dispose(navigator.lastItem) - navigator.replaceAll(LibraryScreen(vmBook.libraryId, filter)) + navigator.replaceAll(LibraryScreen(libraryId, filter)) }, onBookDownload = vm::onBookDownload, cardWidth = vm.cardWidth.collectAsState().value, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt index d6f3ee95..78c3c0bf 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -30,11 +30,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,6 +60,9 @@ import kotlinx.datetime.format import kotlinx.datetime.toLocalDateTime import snd.komelia.DefaultDateTimeFormats.localDateTimeFormat import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import snd.komelia.ui.LocalKomgaEvents +import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent +import snd.komga.client.sse.KomgaEvent.ThumbnailSeriesEvent import kotlin.math.roundToInt import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.ui.book.BookInfoColumn @@ -83,8 +88,8 @@ private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.1 @Composable fun ImmersiveOneshotContent( series: KomgaSeries, - book: KomeliaBook, - library: KomgaLibrary, + book: KomeliaBook?, + library: KomgaLibrary?, accentColor: Color?, onLibraryClick: (KomgaLibrary) -> Unit, onBookReadClick: (markReadProgress: Boolean) -> Unit, @@ -103,6 +108,18 @@ fun ImmersiveOneshotContent( onExpandChange: (Boolean) -> Unit, ) { var showDownloadConfirmationDialog by remember { mutableStateOf(false) } + val komgaEvents = LocalKomgaEvents.current + var coverData by remember(series.id) { mutableStateOf(SeriesDefaultThumbnailRequest(series.id)) } + LaunchedEffect(series.id) { + komgaEvents.collect { event -> + val eventSeriesId = when (event) { + is ThumbnailSeriesEvent -> event.seriesId + is ThumbnailBookEvent -> event.seriesId + else -> null + } + if (eventSeriesId == series.id) coverData = SeriesDefaultThumbnailRequest(series.id) + } + } val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current @@ -133,7 +150,7 @@ fun ImmersiveOneshotContent( Box(modifier = Modifier.fillMaxSize()) { ImmersiveDetailScaffold( - coverData = SeriesDefaultThumbnailRequest(series.id), + coverData = coverData, coverKey = series.id.value, cardColor = accentColor, immersive = true, @@ -142,170 +159,30 @@ fun ImmersiveOneshotContent( topBarContent = {}, // Fixed overlay handles this fabContent = {}, // Fixed overlay handles this cardContent = { expandFraction -> - val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) - val thumbnailTopGap = 20.dp - val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp - - val navBarBottom = with(LocalDensity.current) { - WindowInsets.navigationBars.getBottom(this).toDp() - } - LazyVerticalGrid( - columns = GridCells.Fixed(1), - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(0.dp), - contentPadding = PaddingValues(bottom = navBarBottom + 80.dp), - ) { - // Collapsed stats line (fades out as card expands) - item(span = { GridItemSpan(maxLineSpan) }) { - val alpha = (1f - expandFraction * 2f).coerceIn(0f, 1f) - if (alpha > 0.01f) - BookStatsLine(book, Modifier - .padding(start = 16.dp, end = 16.dp, top = 4.dp) - .graphicsLayer { this.alpha = alpha }) - } - - // Header: book title + writers (year) - item { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) - .padding( - start = 16.dp, - end = 16.dp, - top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, - ) - ) { - if (expandFraction > 0.01f) { - Box( - modifier = Modifier - .padding(top = (thumbnailTopGap - 8.dp) * expandFraction) - .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } - ) { - ThumbnailImage( - data = SeriesDefaultThumbnailRequest(series.id), - cacheKey = series.id.value, - crossfade = false, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(width = 110.dp, height = thumbnailHeight) - .clip(RoundedCornerShape(8.dp)) - ) - } - } - - Column(modifier = Modifier.padding(start = thumbnailOffset)) { - // Book title (headlineSmall, bold) - Text( - text = book.metadata.title, - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - ), - ) - // Writers (year) — labelSmall - val writers = remember(book.metadata.authors) { - book.metadata.authors - .filter { it.role.lowercase() == "writer" } - .joinToString(", ") { it.name } - } - val year = book.metadata.releaseDate?.year - val writersYearText = buildString { - if (writers.isNotEmpty()) append(writers) - if (year != null) { - if (writers.isNotEmpty()) append(" ") - append("($year)") - } - } - if (writersYearText.isNotEmpty()) { - Text( - text = writersYearText, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(top = 2.dp), - ) - } - } - } - } - - // Expanded stats line (fades in as card expands) - item(span = { GridItemSpan(maxLineSpan) }) { - val alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) - if (alpha > 0.01f) - BookStatsLine(book, Modifier - .padding(horizontal = 16.dp, vertical = 4.dp) - .graphicsLayer { this.alpha = alpha }) - } - - // SeriesDescriptionRow (library, status, age rating, etc.) - item(span = { GridItemSpan(maxLineSpan) }) { - SeriesDescriptionRow( - library = library, - onLibraryClick = onLibraryClick, - releaseDate = null, - status = null, - ageRating = series.metadata.ageRating, - language = series.metadata.language, - readingDirection = series.metadata.readingDirection, - deleted = series.deleted || library.unavailable, - alternateTitles = series.metadata.alternateTitles, - onFilterClick = onFilterClick, - showReleaseYear = false, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - - // Summary - if (book.metadata.summary.isNotBlank()) { - item { - Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = book.metadata.summary, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - - // Divider - item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } - - // Book metadata (authors, tags, links, file info, ISBN) - item { - Box(Modifier.padding(horizontal = 16.dp)) { - BookInfoColumn( - publisher = series.metadata.publisher, - genres = series.metadata.genres, - authors = book.metadata.authors, - tags = book.metadata.tags, - links = book.metadata.links, - sizeInMiB = book.size, - mediaType = book.media.mediaType, - isbn = book.metadata.isbn, - fileUrl = book.url, - onFilterClick = onFilterClick, - ) - } - } - - // Reading lists - item(span = { GridItemSpan(maxLineSpan) }) { - BookReadListsContent( - readLists = readLists, - onReadListClick = onReadListClick, - onBookClick = onReadlistBookClick, - cardWidth = cardWidth, - ) - } - - // Collections - item(span = { GridItemSpan(maxLineSpan) }) { - SeriesCollectionsContent( - collections = collections, - onCollectionClick = onCollectionClick, - onSeriesClick = onSeriesClick, - cardWidth = cardWidth, - ) + if (book == null || library == null) { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 48.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } + } else { + OneshotCardContent( + series = series, + book = book, + library = library, + coverData = coverData, + expandFraction = expandFraction, + onLibraryClick = onLibraryClick, + onFilterClick = onFilterClick, + readLists = readLists, + onReadListClick = onReadListClick, + onReadlistBookClick = onReadlistBookClick, + collections = collections, + onCollectionClick = onCollectionClick, + onSeriesClick = onSeriesClick, + cardWidth = cardWidth, + ) } } ) @@ -330,24 +207,26 @@ fun ImmersiveOneshotContent( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) } - var expandActions by remember { mutableStateOf(false) } - Box { - Box( - modifier = Modifier - .size(36.dp) - .background(Color.Black.copy(alpha = 0.55f), CircleShape) - .clickable { expandActions = true }, - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White) + if (book != null) { + var expandActions by remember { mutableStateOf(false) } + Box { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { expandActions = true }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White) + } + OneshotActionsMenu( + series = series, + book = book, + actions = oneshotMenuActions, + expanded = expandActions, + onDismissRequest = { expandActions = false }, + ) } - OneshotActionsMenu( - series = series, - book = book, - actions = oneshotMenuActions, - expanded = expandActions, - onDismissRequest = { expandActions = false }, - ) } } @@ -361,16 +240,16 @@ fun ImmersiveOneshotContent( .padding(bottom = 16.dp) ) { ImmersiveDetailFab( - onReadClick = { onBookReadClick(true) }, - onReadIncognitoClick = { onBookReadClick(false) }, - onDownloadClick = { showDownloadConfirmationDialog = true }, + onReadClick = { if (book != null) onBookReadClick(true) }, + onReadIncognitoClick = { if (book != null) onBookReadClick(false) }, + onDownloadClick = { if (book != null) showDownloadConfirmationDialog = true }, accentColor = accentColor, - showReadActions = true, + showReadActions = book != null, ) } } - if (showDownloadConfirmationDialog) { + if (showDownloadConfirmationDialog && book != null) { var permissionRequested by remember { mutableStateOf(false) } DownloadNotificationRequestDialog { permissionRequested = true } if (permissionRequested) { @@ -386,6 +265,190 @@ fun ImmersiveOneshotContent( } } +@Composable +private fun OneshotCardContent( + series: KomgaSeries, + book: KomeliaBook, + library: KomgaLibrary, + coverData: Any, + expandFraction: Float, + onLibraryClick: (KomgaLibrary) -> Unit, + onFilterClick: (SeriesScreenFilter) -> Unit, + readLists: Map>, + onReadListClick: (KomgaReadList) -> Unit, + onReadlistBookClick: (KomeliaBook, KomgaReadList) -> Unit, + collections: Map>, + onCollectionClick: (KomgaCollection) -> Unit, + onSeriesClick: (KomgaSeries) -> Unit, + cardWidth: Dp, +) { + val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) + val thumbnailTopGap = 20.dp + val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp + + val navBarBottom = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + LazyVerticalGrid( + columns = GridCells.Fixed(1), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(bottom = navBarBottom + 80.dp), + ) { + // Collapsed stats line (fades out as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (1f - expandFraction * 2f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(book, Modifier + .padding(start = 16.dp, end = 16.dp, top = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // Header: book title + writers (year) + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) + .padding( + start = 16.dp, + end = 16.dp, + top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, + ) + ) { + if (expandFraction > 0.01f) { + Box( + modifier = Modifier + .padding(top = (thumbnailTopGap - 8.dp) * expandFraction) + .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } + ) { + ThumbnailImage( + data = coverData, + cacheKey = series.id.value, + crossfade = false, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = thumbnailHeight) + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + Column(modifier = Modifier.padding(start = thumbnailOffset)) { + // Book title (headlineSmall, bold) + Text( + text = book.metadata.title, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + ) + // Writers (year) — labelSmall + val writers = remember(book.metadata.authors) { + book.metadata.authors + .filter { it.role.lowercase() == "writer" } + .joinToString(", ") { it.name } + } + val year = book.metadata.releaseDate?.year + val writersYearText = buildString { + if (writers.isNotEmpty()) append(writers) + if (year != null) { + if (writers.isNotEmpty()) append(" ") + append("($year)") + } + } + if (writersYearText.isNotEmpty()) { + Text( + text = writersYearText, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + + // Expanded stats line (fades in as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(book, Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // SeriesDescriptionRow (library, status, age rating, etc.) + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesDescriptionRow( + library = library, + onLibraryClick = onLibraryClick, + releaseDate = null, + status = null, + ageRating = series.metadata.ageRating, + language = series.metadata.language, + readingDirection = series.metadata.readingDirection, + deleted = series.deleted || library.unavailable, + alternateTitles = series.metadata.alternateTitles, + onFilterClick = onFilterClick, + showReleaseYear = false, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + // Summary + if (book.metadata.summary.isNotBlank()) { + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = book.metadata.summary, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Divider + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + + // Book metadata (authors, tags, links, file info, ISBN) + item { + Box(Modifier.padding(horizontal = 16.dp)) { + BookInfoColumn( + publisher = series.metadata.publisher, + genres = series.metadata.genres, + authors = book.metadata.authors, + tags = book.metadata.tags, + links = book.metadata.links, + sizeInMiB = book.size, + mediaType = book.media.mediaType, + isbn = book.metadata.isbn, + fileUrl = book.url, + onFilterClick = onFilterClick, + ) + } + } + + // Reading lists + item(span = { GridItemSpan(maxLineSpan) }) { + BookReadListsContent( + readLists = readLists, + onReadListClick = onReadListClick, + onBookClick = onReadlistBookClick, + cardWidth = cardWidth, + ) + } + + // Collections + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesCollectionsContent( + collections = collections, + onCollectionClick = onCollectionClick, + onSeriesClick = onSeriesClick, + cardWidth = cardWidth, + ) + } + } +} + @Composable private fun BookStatsLine(book: KomeliaBook, modifier: Modifier = Modifier) { val pagesCount = book.media.pagesCount diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt index 8ebd880e..8627889a 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt @@ -46,6 +46,9 @@ import androidx.compose.ui.util.lerp import snd.komelia.image.coil.SeriesDefaultThumbnailRequest import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.ui.LoadState +import snd.komelia.ui.LocalKomgaEvents +import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent +import snd.komga.client.sse.KomgaEvent.ThumbnailSeriesEvent import snd.komelia.ui.collection.SeriesCollectionsContent import snd.komelia.ui.collection.SeriesCollectionsState import snd.komelia.ui.common.components.AppFilterChipDefaults @@ -145,8 +148,21 @@ fun ImmersiveSeriesContent( } } + val komgaEvents = LocalKomgaEvents.current + var coverData by remember(series.id) { mutableStateOf(SeriesDefaultThumbnailRequest(series.id)) } + LaunchedEffect(series.id) { + komgaEvents.collect { event -> + val eventSeriesId = when (event) { + is ThumbnailSeriesEvent -> event.seriesId + is ThumbnailBookEvent -> event.seriesId + else -> null + } + if (eventSeriesId == series.id) coverData = SeriesDefaultThumbnailRequest(series.id) + } + } + ImmersiveDetailScaffold( - coverData = SeriesDefaultThumbnailRequest(series.id), + coverData = coverData, coverKey = series.id.value, cardColor = accentColor, immersive = true, @@ -258,7 +274,7 @@ fun ImmersiveSeriesContent( .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } ) { ThumbnailImage( - data = SeriesDefaultThumbnailRequest(series.id), + data = coverData, cacheKey = series.id.value, crossfade = false, contentScale = ContentScale.Crop, From 4548c2fef8ad774976113986de00fba43d008f78 Mon Sep 17 00:00:00 2001 From: eserero Date: Sun, 1 Mar 2026 23:12:04 +0200 Subject: [PATCH 29/35] feat(ui): enhance immersive screens and navigation with adaptive colors and accent presets --- .../kotlin/snd/komelia/ui/MainScreen.kt | 29 +++++++++++++++---- .../ui/book/immersive/ImmersiveBookContent.kt | 2 +- .../ui/common/immersive/ImmersiveDetailFab.kt | 27 +++++++++++++++-- .../immersive/ImmersiveOneshotContent.kt | 2 +- .../immersive/ImmersiveSeriesContent.kt | 2 +- .../appearance/AppearanceSettingsContent.kt | 20 +++++++++---- .../ui/topbar/NavigationMenuContent.kt | 19 ++++++++---- 7 files changed, 79 insertions(+), 22 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index 7f8f3108..0be5b5ef 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.core.tween @@ -54,6 +55,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -249,6 +251,16 @@ class MainScreen( toggleLibrariesDrawer: () -> Unit, ) { val containerColor = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surface + val accentColor = LocalAccentColor.current + val itemColors = if (accentColor != null) { + NavigationBarItemDefaults.colors( + selectedIconColor = if (accentColor.luminance() > 0.5f) Color.Black else Color.White, + selectedTextColor = MaterialTheme.colorScheme.primary, + indicatorColor = accentColor + ) + } else { + NavigationBarItemDefaults.colors() + } NavigationBar( containerColor = containerColor, ) { @@ -257,21 +269,24 @@ class MainScreen( selected = false, onClick = toggleLibrariesDrawer, icon = { Icon(Icons.Rounded.LocalLibrary, null) }, - label = { Text("Libraries") } + label = { Text("Libraries") }, + colors = itemColors ) NavigationBarItem( alwaysShowLabel = true, selected = navigator.lastItem is HomeScreen, onClick = { if (navigator.lastItem !is HomeScreen) navigator.replaceAll(HomeScreen()) }, icon = { Icon(Icons.Rounded.Home, null) }, - label = { Text("Home") } + label = { Text("Home") }, + colors = itemColors ) NavigationBarItem( alwaysShowLabel = true, selected = navigator.lastItem is SearchScreen, onClick = { if (navigator.lastItem !is SearchScreen) navigator.push(SearchScreen(null)) }, icon = { Icon(Icons.Rounded.Search, null) }, - label = { Text("Search") } + label = { Text("Search") }, + colors = itemColors ) NavigationBarItem( alwaysShowLabel = true, @@ -281,7 +296,8 @@ class MainScreen( navigator.push(MobileSettingsScreen()) }, icon = { Icon(Icons.Rounded.Settings, null) }, - label = { Text("Settings") } + label = { Text("Settings") }, + colors = itemColors ) } } @@ -348,11 +364,12 @@ class MainScreen( isSelected: Boolean, modifier: Modifier ) { + val accentColor = LocalAccentColor.current Surface( modifier = modifier, contentColor = - if (isSelected) MaterialTheme.colorScheme.secondary - else contentColorFor(MaterialTheme.colorScheme.surfaceVariant) + if (isSelected) accentColor ?: MaterialTheme.colorScheme.secondary + else contentColorFor(MaterialTheme.colorScheme.surfaceVariant) ) { Column( modifier = Modifier diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt index 532bb76d..815ed907 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -196,7 +196,7 @@ fun ImmersiveBookContent( ImmersiveDetailScaffold( coverData = coverData, coverKey = pageBook.id.value, - cardColor = accentColor, + cardColor = null, immersive = true, initiallyExpanded = initiallyExpanded, onExpandChange = onExpandChange, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt index db80b557..5af1d937 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt @@ -19,8 +19,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.unit.dp import snd.komelia.ui.LocalNavBarColor +import snd.komelia.ui.LocalTheme +import snd.komelia.ui.Theme @Composable fun ImmersiveDetailFab( @@ -30,8 +33,24 @@ fun ImmersiveDetailFab( accentColor: Color? = null, showReadActions: Boolean = true, ) { + val theme = LocalTheme.current val navBarColor = LocalNavBarColor.current - val readNowContainerColor = navBarColor ?: MaterialTheme.colorScheme.primaryContainer + + val (fabContainerColor, fabContentColor) = if (theme.type == Theme.ThemeType.LIGHT) { + Color(red = 43, green = 43, blue = 43) to Color.White + } else { + MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + } + + val readNowContainerColor = accentColor + ?: if (theme.type == Theme.ThemeType.LIGHT) Color(red = 43, green = 43, blue = 43) + else navBarColor ?: MaterialTheme.colorScheme.primaryContainer + + val readNowContentColor = if (accentColor != null || (theme.type == Theme.ThemeType.DARK && navBarColor != null)) { + if (readNowContainerColor.luminance() > 0.5f) Color.Black else Color.White + } else { + contentColorFor(readNowContainerColor) + } Row( horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -46,7 +65,7 @@ fun ImmersiveDetailFab( ExtendedFloatingActionButton( onClick = onReadClick, containerColor = readNowContainerColor, - contentColor = contentColorFor(readNowContainerColor), + contentColor = readNowContentColor, icon = { Icon( Icons.AutoMirrored.Rounded.MenuBook, @@ -58,6 +77,8 @@ fun ImmersiveDetailFab( FloatingActionButton( onClick = onReadIncognitoClick, + containerColor = fabContainerColor, + contentColor = fabContentColor, ) { Icon( Icons.Rounded.VisibilityOff, @@ -69,6 +90,8 @@ fun ImmersiveDetailFab( // Download FAB FloatingActionButton( onClick = onDownloadClick, + containerColor = fabContainerColor, + contentColor = fabContentColor, ) { Icon( Icons.Rounded.Download, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt index 78c3c0bf..643bf893 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -152,7 +152,7 @@ fun ImmersiveOneshotContent( ImmersiveDetailScaffold( coverData = coverData, coverKey = series.id.value, - cardColor = accentColor, + cardColor = null, immersive = true, initiallyExpanded = initiallyExpanded, onExpandChange = onExpandChange, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt index 8627889a..ccdafa61 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt @@ -164,7 +164,7 @@ fun ImmersiveSeriesContent( ImmersiveDetailScaffold( coverData = coverData, coverKey = series.id.value, - cardColor = accentColor, + cardColor = null, immersive = true, initiallyExpanded = initiallyExpanded, onExpandChange = onExpandChange, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt index 9c0ed76b..bddd1d91 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt @@ -40,6 +40,11 @@ import kotlin.math.roundToInt private val navBarPresets: List> = listOf( null to "Auto", + Color(0xFF800020.toInt()) to "Burgundy", + Color(0xFFE57373.toInt()) to "Muted Red", + Color(0xFF5783D4.toInt()) to "Secondary Blue", + Color(0xFF201F23.toInt()) to "Toolbar (Dark)", + Color(0xFFE1E1E1.toInt()) to "Toolbar (Light)", Color(0xFF2D3436.toInt()) to "Charcoal", Color(0xFF1A1A2E.toInt()) to "Navy", Color(0xFF0D3B46.toInt()) to "D.Teal", @@ -55,17 +60,22 @@ private val navBarPresets: List> = listOf( private val accentPresets: List> = listOf( null to "Auto", + Color(0xFF800020.toInt()) to "Burgundy", + Color(0xFFE57373.toInt()) to "Muted Red", + Color(0xFF5783D4.toInt()) to "Secondary Blue", + Color(0xFF201F23.toInt()) to "Toolbar (Dark)", + Color(0xFFE1E1E1.toInt()) to "Toolbar (Light)", + Color(0xFF2D3436.toInt()) to "Charcoal", + Color(0xFF1A1A2E.toInt()) to "Navy", + Color(0xFF0D3B46.toInt()) to "D.Teal", + Color(0xFF1B4332.toInt()) to "Forest", + Color(0xFF3D1A78.toInt()) to "Violet", Color(0xFF3B82F6.toInt()) to "Blue", Color(0xFF14B8A6.toInt()) to "Teal", Color(0xFF8B5CF6.toInt()) to "Purple", Color(0xFFEC4899.toInt()) to "Pink", Color(0xFFF97316.toInt()) to "Orange", Color(0xFF22C55E.toInt()) to "Green", - Color(0xFF2D3436.toInt()) to "Charcoal", - Color(0xFF0D3B46.toInt()) to "D.Teal", - Color(0xFF1A1A2E.toInt()) to "Navy", - Color(0xFF1B4332.toInt()) to "Forest", - Color(0xFF3D1A78.toInt()) to "Violet", ) @Composable diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/topbar/NavigationMenuContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/topbar/NavigationMenuContent.kt index e6d8b24d..c9e7a8f6 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/topbar/NavigationMenuContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/topbar/NavigationMenuContent.kt @@ -49,9 +49,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen +import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalKomgaState import snd.komelia.ui.LocalOfflineMode import snd.komelia.ui.common.menus.LibraryActionsMenu @@ -255,10 +257,17 @@ private fun NavButton( actionButton: (@Composable () -> Unit)? = null, isSelected: Boolean, ) { + val accentColor = LocalAccentColor.current + val (backgroundColor, contentColor) = when { + isSelected && accentColor != null -> accentColor.copy(alpha = 0.15f) to accentColor + isSelected -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + else -> Color.Transparent to MaterialTheme.colorScheme.onSurface + } + TextButton( onClick = onClick, contentPadding = PaddingValues(0.dp), - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(10.dp), ) { Row( horizontalArrangement = Arrangement.Start, @@ -266,16 +275,14 @@ private fun NavButton( modifier = Modifier .fillMaxWidth() .height(40.dp) - .background( - if (isSelected) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surface - ) + .background(backgroundColor) ) { if (icon != null) { Icon( icon, contentDescription = null, + tint = contentColor, modifier = Modifier.padding(10.dp, 0.dp, 20.dp, 0.dp) ) } else { @@ -283,7 +290,7 @@ private fun NavButton( } Column(modifier = Modifier.weight(1.0f)) { - Text(label, style = MaterialTheme.typography.labelLarge) + Text(label, style = MaterialTheme.typography.labelLarge, color = contentColor) if (errorLabel != null) { Text( text = errorLabel, From 4d960b6568d2935840f7b4e6656a88359bcd1474 Mon Sep 17 00:00:00 2001 From: eserero Date: Mon, 2 Mar 2026 00:17:48 +0200 Subject: [PATCH 30/35] style(ui): strictly enforce 1-line-per-segment in 'Below' card layout --- CARD_LAYOUT_PLAN.md | 67 ++++++++++++++ .../settings/CommonSettingsRepository.kt | 3 + .../kotlin/snd/komelia/db/AppSettings.kt | 1 + .../repository/SettingsRepositoryWrapper.kt | 8 ++ .../migrations/app/V19__card_layout_below.sql | 1 + .../komelia/db/migrations/AppMigrations.kt | 1 + .../db/settings/ExposedSettingsRepository.kt | 2 + .../snd/komelia/db/tables/AppSettingsTable.kt | 1 + .../snd/komelia/ui/CompositionLocals.kt | 1 + .../kotlin/snd/komelia/ui/MainView.kt | 6 ++ .../komelia/ui/common/cards/BookItemCard.kt | 87 +++++++++++++++++-- .../ui/common/cards/CollectionItemCard.kt | 47 ++++++++-- .../snd/komelia/ui/common/cards/ItemCard.kt | 23 ++++- .../ui/common/cards/ReadListItemCard.kt | 51 +++++++++-- .../komelia/ui/common/cards/SeriesItemCard.kt | 63 +++++++++++++- .../settings/appearance/AppSettingsScreen.kt | 17 ++-- .../appearance/AppSettingsViewModel.kt | 7 ++ .../appearance/AppearanceSettingsContent.kt | 73 ++++++++++++++-- 18 files changed, 421 insertions(+), 38 deletions(-) create mode 100644 CARD_LAYOUT_PLAN.md create mode 100644 komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V19__card_layout_below.sql diff --git a/CARD_LAYOUT_PLAN.md b/CARD_LAYOUT_PLAN.md new file mode 100644 index 00000000..8e963d20 --- /dev/null +++ b/CARD_LAYOUT_PLAN.md @@ -0,0 +1,67 @@ +# Proposal: Adaptive Library Card Layout + +## Objective +Introduce a new layout option for library items (Books, Series, Collections, Read Lists) that places text metadata below the thumbnail in a structured Material 3 Filled Card, improving readability and providing a more traditional "bookshelf" aesthetic. + +## 1. User Interface Changes + +### Appearance Settings +- **New Toggle**: `Card Layout` +- **Options**: + - `Overlay` (Current default): Text appears on top of the thumbnail with a gradient overlay. + - `Below`: Text appears in a dedicated area below the thumbnail. +- **Description**: "Show title and metadata below the thumbnail instead of on top." +- **Preview**: The card size slider preview in the settings will adapt to show the selected layout. + +### Card Design (`Below` Layout) +- **Dimensions**: + - **Width**: Strictly aligned to the `cardWidth` setting (same as `Overlay` layout). + - **Height**: Calculated dynamically based on the thumbnail's aspect ratio plus the fixed height of the text area. +- **Container**: + - **Type**: Material 3 Filled Card. + - **Colors**: `surfaceContainerHighest` for the container, following Material 3 guidelines for both Light and Dark themes. + - **Corners**: The card container will have rounded corners on all four sides (M3 standard, typically 12dp). +- **Thumbnail**: + - Fills the top, left, and right edges of the card. + - Maintains the existing 0.703 aspect ratio. + - **Corners**: Rounded corners **only on the top** to match the card's top profile; bottom of the thumbnail remains square where it meets the text area. +- **Text Area**: + - Located directly beneath the thumbnail. + - **Title**: Maximum of 2 lines, ellipsis on overflow. + - **Padding**: 8dp to 10dp padding around text elements to ensure M3 spacing standards. + +## 2. Technical Implementation + +### Persistence (`komelia-domain` & `komelia-infra`) +- Add `cardLayoutBelow` to `AppSettings` data class in `komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt`. +- **Database Migrations**: + - Add `V19__card_layout_below.sql` migration for SQLite (Android/Desktop) in `komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/`. + - Update `AppSettingsTable.kt` in `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/` to include the new column. + - Update `ExposedSettingsRepository.kt` in `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/` to map the new column to the `AppSettings` object. +- Update `CommonSettingsRepository` and `SettingsRepositoryWrapper` to handle this new preference. + +### UI State (`komelia-ui`) +- Define `LocalCardLayoutBelow` in `CompositionLocals.kt`. +- Update `MainView.kt` to collect the setting and provide it to the composition. +- Enhance `ItemCard` in `ItemCard.kt` to act as the layout engine: + - If `Overlay`: Use existing `Box` structure. + - If `Below`: Use `Column` with thumbnail followed by a content area. + +### Component Updates +- **SeriesItemCard.kt**: + - Extract `SeriesImageOverlay` logic. + - Pass title and unread count to `ItemCard`'s content slot when in `Below` mode. +- **BookItemCard.kt**: + - Ensure the read progress bar and "unread" indicators are correctly positioned. + - Handle book titles and series titles (if enabled) in the text area. +- **Other Cards**: Apply similar changes to `CollectionItemCard.kt` and `ReadListItemCard.kt`. + +## 3. Implementation Phases +1. **Phase 1: Domain & Infrastructure**: Update settings storage and repository layers. +2. **Phase 2: Settings UI**: Add the toggle to the Appearance settings screen and ViewModel. +3. **Phase 3: Base Component Refactoring**: Update `ItemCard` to support dual-layout switching. +4. **Phase 4: Content Implementation**: Update Series, Book, Collection, and Read List cards to fill the "Below" layout content slot. +5. **Phase 5: Visual Polish**: Finalize padding, M3 colors, and layout constraints (max 2 lines). + +## 4. Default Behavior +- The default will remain as the `Overlay` layout to preserve the current user experience until explicitly changed by the user. diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt index d84c1523..700a669e 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt @@ -48,4 +48,7 @@ interface CommonSettingsRepository { fun getUseNewLibraryUI(): Flow suspend fun putUseNewLibraryUI(enabled: Boolean) + + fun getCardLayoutBelow(): Flow + suspend fun putCardLayoutBelow(enabled: Boolean) } \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt index a5d1081e..1136ec8e 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt @@ -25,4 +25,5 @@ data class AppSettings( val navBarColor: Long? = null, val accentColor: Long? = null, val useNewLibraryUI: Boolean = true, + val cardLayoutBelow: Boolean = false, ) diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt index cd06e644..cfc07c07 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt @@ -127,4 +127,12 @@ class SettingsRepositoryWrapper( wrapper.transform { it.copy(useNewLibraryUI = enabled) } } + override fun getCardLayoutBelow(): Flow { + return wrapper.state.map { it.cardLayoutBelow }.distinctUntilChanged() + } + + override suspend fun putCardLayoutBelow(enabled: Boolean) { + wrapper.transform { it.copy(cardLayoutBelow = enabled) } + } + } \ No newline at end of file diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V19__card_layout_below.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V19__card_layout_below.sql new file mode 100644 index 00000000..10717e77 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V19__card_layout_below.sql @@ -0,0 +1 @@ +ALTER TABLE AppSettings ADD COLUMN card_layout_below INTEGER NOT NULL DEFAULT 0; diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt index 4e4b5e2d..556d44ea 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt @@ -25,6 +25,7 @@ class AppMigrations : MigrationResourcesProvider() { "V16__panel_reader_settings.sql", "V17__reader_tap_settings.sql", "V18__reader_adaptive_background.sql", + "V19__card_layout_below.sql", ) override suspend fun getMigration(name: String): ByteArray? { diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt index 3d06465b..00103a98 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt @@ -42,6 +42,7 @@ class ExposedSettingsRepository(database: Database) : ExposedRepository(database it[navBarColor] = settings.navBarColor?.toString(16) it[accentColor] = settings.accentColor?.toString(16) it[useNewLibraryUI] = settings.useNewLibraryUI + it[cardLayoutBelow] = settings.cardLayoutBelow } } } @@ -66,6 +67,7 @@ class ExposedSettingsRepository(database: Database) : ExposedRepository(database navBarColor = get(AppSettingsTable.navBarColor)?.toLong(16), accentColor = get(AppSettingsTable.accentColor)?.toLong(16), useNewLibraryUI = get(AppSettingsTable.useNewLibraryUI), + cardLayoutBelow = get(AppSettingsTable.cardLayoutBelow), ) } diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt index 1968d1de..94e9a187 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt @@ -25,6 +25,7 @@ object AppSettingsTable : Table("AppSettings") { val navBarColor = text("nav_bar_color").nullable() val accentColor = text("accent_color").nullable() val useNewLibraryUI = bool("use_new_library_ui").default(true) + val cardLayoutBelow = bool("card_layout_below").default(false) override val primaryKey = PrimaryKey(version) } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt index a58d27f3..0260a172 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt @@ -44,6 +44,7 @@ val LocalKomgaState = staticCompositionLocalOf { error val LocalNavBarColor = compositionLocalOf { null } val LocalAccentColor = compositionLocalOf { null } val LocalUseNewLibraryUI = compositionLocalOf { true } +val LocalCardLayoutBelow = compositionLocalOf { false } val LocalRawStatusBarHeight = staticCompositionLocalOf { 0.dp } @OptIn(ExperimentalSharedTransitionApi::class) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt index 77c5e057..c4eb3474 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt @@ -74,6 +74,7 @@ fun MainView( var navBarColor by remember { mutableStateOf(null) } var accentColor by remember { mutableStateOf(null) } var useNewLibraryUI by remember { mutableStateOf(true) } + var cardLayoutBelow by remember { mutableStateOf(false) } LaunchedEffect(dependencies) { dependencies?.appRepositories?.settingsRepository?.getAppTheme()?.collect { theme = it.toTheme() } } @@ -89,6 +90,10 @@ fun MainView( dependencies?.appRepositories?.settingsRepository?.getUseNewLibraryUI() ?.collect { useNewLibraryUI = it } } + LaunchedEffect(dependencies) { + dependencies?.appRepositories?.settingsRepository?.getCardLayoutBelow() + ?.collect { cardLayoutBelow = it } + } MaterialTheme(colorScheme = theme.colorScheme) { ConfigurePlatformTheme(theme) @@ -136,6 +141,7 @@ fun MainView( LocalNavBarColor provides navBarColor, LocalAccentColor provides accentColor, LocalUseNewLibraryUI provides useNewLibraryUI, + LocalCardLayoutBelow provides cardLayoutBelow, ) { MainContent(platformType, dependencies.komgaSharedState) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt index b59d797f..86fd7fc3 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt @@ -47,13 +47,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.filter import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.offline.sync.model.DownloadEvent import snd.komelia.ui.LocalBookDownloadEvents +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalLibraries import snd.komelia.ui.LocalWindowWidth import snd.komelia.ui.common.BookReadButton @@ -81,6 +85,8 @@ fun BookImageCard( val libraryIsDeleted = remember { libraries.value.firstOrNull { it.id == book.libraryId }?.unavailable ?: false } + val cardLayoutBelow = LocalCardLayoutBelow.current + ItemCard( modifier = modifier, onClick = onBookClick, @@ -97,7 +103,8 @@ fun BookImageCard( BookImageOverlay( book = book, libraryIsDeleted = libraryIsDeleted, - showSeriesTitle = showSeriesTitle, + showTitle = !cardLayoutBelow, + showSeriesTitle = showSeriesTitle && !cardLayoutBelow, ) { BookThumbnail( book.id, @@ -106,6 +113,55 @@ fun BookImageCard( ) } } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + val isUnavailable = book.deleted || libraryIsDeleted + val showSeries = showSeriesTitle && !book.oneshot + + if (isUnavailable) { + Text( + text = "Unavailable", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = book.metadata.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } else if (showSeries) { + Text( + text = book.seriesTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = book.metadata.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = book.metadata.title, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } ) } @@ -116,6 +172,7 @@ fun BookSimpleImageCard( onBookClick: (() -> Unit)? = null, modifier: Modifier = Modifier ) { + val cardLayoutBelow = LocalCardLayoutBelow.current ItemCard( modifier = modifier, onClick = onBookClick, @@ -123,7 +180,7 @@ fun BookSimpleImageCard( BookImageOverlay( book = book, libraryIsDeleted = false, - showTitle = false + showTitle = !cardLayoutBelow ) { BookThumbnail( book.id, @@ -131,6 +188,20 @@ fun BookSimpleImageCard( contentScale = ContentScale.Crop ) } + }, + content = { + if (cardLayoutBelow) { + Column(Modifier.padding(8.dp)) { + Text( + text = book.metadata.title, + maxLines = 2, + minLines = 2, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + ) + } + } } ) } @@ -183,12 +254,12 @@ private fun BookImageOverlay( text = book.metadata.title, maxLines = DEFAULT_CARD_MAX_LINES ) - } - if (book.deleted || libraryIsDeleted) { - CardOutlinedText( - text = "Unavailable", - textColor = MaterialTheme.colorScheme.error - ) + if (book.deleted || libraryIsDeleted) { + CardOutlinedText( + text = "Unavailable", + textColor = MaterialTheme.colorScheme.error + ) + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/CollectionItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/CollectionItemCard.kt index 51757961..879ea988 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/CollectionItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/CollectionItemCard.kt @@ -3,6 +3,7 @@ package snd.komelia.ui.common.cards import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,6 +16,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -25,7 +27,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalKomgaState import snd.komelia.ui.common.images.CollectionThumbnail import snd.komelia.ui.common.menus.CollectionActionsMenu @@ -38,12 +46,16 @@ fun CollectionImageCard( onCollectionDelete: () -> Unit, modifier: Modifier = Modifier ) { + val cardLayoutBelow = LocalCardLayoutBelow.current ItemCard( modifier = modifier, onClick = onCollectionClick, image = { CollectionCardHoverOverlay(collection, onCollectionDelete) { - CollectionImageOverlay(collection) { + CollectionImageOverlay( + collection = collection, + showTitle = !cardLayoutBelow, + ) { CollectionThumbnail( collectionId = collection.id, modifier = Modifier.fillMaxSize(), @@ -51,6 +63,28 @@ fun CollectionImageCard( ) } } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = collection.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "${collection.seriesIds.size} series", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } ) } @@ -108,6 +142,7 @@ private fun CollectionCardHoverOverlay( @Composable private fun CollectionImageOverlay( collection: KomgaCollection, + showTitle: Boolean = true, content: @Composable () -> Unit ) { @@ -116,10 +151,12 @@ private fun CollectionImageOverlay( contentAlignment = Alignment.BottomStart ) { content() - CardGradientOverlay() - Column(Modifier.padding(10.dp)) { - CardOutlinedText(collection.name) - CardOutlinedText("${collection.seriesIds.size} series") + if (showTitle) { + CardGradientOverlay() + Column(Modifier.padding(10.dp)) { + CardOutlinedText(collection.name) + CardOutlinedText("${collection.seriesIds.size} series") + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt index f54be470..eb11aa0f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyGridState +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalPlatform import snd.komelia.ui.common.components.OutlinedText import snd.komelia.ui.platform.PlatformType @@ -47,20 +48,34 @@ const val DEFAULT_CARD_MAX_LINES = 2 @Composable fun ItemCard( modifier: Modifier = Modifier, - containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, + containerColor: Color? = null, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, image: @Composable () -> Unit, content: @Composable ColumnScope.() -> Unit = {}, ) { + val cardLayoutBelow = LocalCardLayoutBelow.current + val color = containerColor ?: if (cardLayoutBelow) Color.Transparent + else MaterialTheme.colorScheme.surfaceVariant + + val shape = if (cardLayoutBelow) RoundedCornerShape(12.dp) + else RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp) + Card( - shape = RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp), + shape = shape, modifier = modifier .combinedClickable(onClick = onClick ?: {}, onLongClick = onLongClick) .then(if (onClick != null || onLongClick != null) Modifier.cursorForHand() else Modifier), - colors = CardDefaults.cardColors(containerColor = containerColor), + colors = CardDefaults.cardColors(containerColor = color), ) { - Box(modifier = Modifier.aspectRatio(0.703f)) { image() } + val imageShape = if (cardLayoutBelow) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + else RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp) + + Box( + modifier = Modifier + .aspectRatio(0.703f) + .clip(imageShape) + ) { image() } content() } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ReadListItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ReadListItemCard.kt index 354f7e86..902214e8 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ReadListItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ReadListItemCard.kt @@ -3,6 +3,7 @@ package snd.komelia.ui.common.cards import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,6 +16,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -25,7 +27,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalKomgaState import snd.komelia.ui.common.images.ReadListThumbnail import snd.komelia.ui.common.menus.ReadListActionsMenu @@ -38,12 +46,16 @@ fun ReadListImageCard( onCollectionDelete: () -> Unit, modifier: Modifier = Modifier ) { + val cardLayoutBelow = LocalCardLayoutBelow.current ItemCard( modifier = modifier, onClick = onCollectionClick, image = { ReadListCardHoverOverlay(readLists, onCollectionDelete) { - ReadListImageOverlay(readLists) { + ReadListImageOverlay( + readlist = readLists, + showTitle = !cardLayoutBelow, + ) { ReadListThumbnail( readListId = readLists.id, modifier = Modifier.fillMaxSize(), @@ -51,6 +63,28 @@ fun ReadListImageCard( ) } } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = readLists.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = if (readLists.bookIds.size == 1) "1 book" else "${readLists.bookIds.size} books", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } ) } @@ -106,6 +140,7 @@ private fun ReadListCardHoverOverlay( @Composable private fun ReadListImageOverlay( readlist: KomgaReadList, + showTitle: Boolean = true, content: @Composable () -> Unit ) { @@ -114,12 +149,14 @@ private fun ReadListImageOverlay( contentAlignment = Alignment.BottomStart ) { content() - CardGradientOverlay() - Column(Modifier.padding(10.dp)) { - CardOutlinedText(readlist.name) - CardOutlinedText( - if (readlist.bookIds.size == 1) "1 book" else "${readlist.bookIds.size} books", - ) + if (showTitle) { + CardGradientOverlay() + Column(Modifier.padding(10.dp)) { + CardOutlinedText(readlist.name) + CardOutlinedText( + if (readlist.bookIds.size == 1) "1 book" else "${readlist.bookIds.size} books", + ) + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt index cd5ce007..74fa9863 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt @@ -34,8 +34,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalLibraries import snd.komelia.ui.common.components.NoPaddingChip import snd.komelia.ui.common.images.SeriesThumbnail @@ -57,6 +62,8 @@ fun SeriesImageCard( val libraryIsDeleted = remember { libraries.value.firstOrNull { it.id == series.libraryId }?.unavailable ?: false } + val cardLayoutBelow = LocalCardLayoutBelow.current + ItemCard( modifier = modifier, onClick = onSeriesClick, @@ -68,7 +75,11 @@ fun SeriesImageCard( isSelected = isSelected, seriesActions = seriesMenuActions, ) { - SeriesImageOverlay(series = series, libraryIsDeleted = libraryIsDeleted) { + SeriesImageOverlay( + series = series, + libraryIsDeleted = libraryIsDeleted, + showTitle = !cardLayoutBelow + ) { SeriesThumbnail( series.id, modifier = Modifier.fillMaxSize(), @@ -76,6 +87,39 @@ fun SeriesImageCard( ) } } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + val isUnavailable = series.deleted || libraryIsDeleted + if (isUnavailable) { + Text( + text = series.metadata.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "Unavailable", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text( + text = series.metadata.title, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } ) } @@ -86,6 +130,7 @@ fun SeriesSimpleImageCard( onSeriesClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { + val cardLayoutBelow = LocalCardLayoutBelow.current ItemCard( modifier = modifier, onClick = onSeriesClick, @@ -93,7 +138,7 @@ fun SeriesSimpleImageCard( SeriesImageOverlay( series = series, libraryIsDeleted = false, - showTitle = false, + showTitle = !cardLayoutBelow, ) { SeriesThumbnail( series.id, @@ -101,6 +146,20 @@ fun SeriesSimpleImageCard( contentScale = ContentScale.Crop ) } + }, + content = { + if (cardLayoutBelow) { + Column(Modifier.padding(8.dp)) { + Text( + text = series.metadata.title, + maxLines = 2, + minLines = 2, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + ) + } + } } ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt index 499b4eba..9445236f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt @@ -33,10 +33,13 @@ class AppSettingsScreen : Screen { onNavBarColorChange = vm::onNavBarColorChange, accentColor = vm.accentColor, onAccentColorChange = vm::onAccentColorChange, - useNewLibraryUI = vm.useNewLibraryUI, - onUseNewLibraryUIChange = vm::onUseNewLibraryUIChange, - ) - } - } - } -} \ No newline at end of file + useNewLibraryUI = vm.useNewLibraryUI, + onUseNewLibraryUIChange = vm::onUseNewLibraryUIChange, + cardLayoutBelow = vm.cardLayoutBelow, + onCardLayoutBelowChange = vm::onCardLayoutBelowChange, + ) + } + } + } + } + \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt index 89147c47..07480fb9 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt @@ -25,6 +25,7 @@ class AppSettingsViewModel( var navBarColor by mutableStateOf(null) var accentColor by mutableStateOf(null) var useNewLibraryUI by mutableStateOf(true) + var cardLayoutBelow by mutableStateOf(false) suspend fun initialize() { if (state.value !is LoadState.Uninitialized) return @@ -34,6 +35,7 @@ class AppSettingsViewModel( navBarColor = settingsRepository.getNavBarColor().first()?.let { Color(it.toInt()) } accentColor = settingsRepository.getAccentColor().first()?.let { Color(it.toInt()) } useNewLibraryUI = settingsRepository.getUseNewLibraryUI().first() + cardLayoutBelow = settingsRepository.getCardLayoutBelow().first() mutableState.value = LoadState.Success(Unit) } @@ -62,4 +64,9 @@ class AppSettingsViewModel( screenModelScope.launch { settingsRepository.putUseNewLibraryUI(enabled) } } + fun onCardLayoutBelowChange(enabled: Boolean) { + this.cardLayoutBelow = enabled + screenModelScope.launch { settingsRepository.putCardLayoutBelow(enabled) } + } + } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt index bddd1d91..3ccd4106 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -23,6 +24,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import snd.komelia.ui.LocalCardLayoutBelow +import androidx.compose.runtime.CompositionLocalProvider +import snd.komelia.ui.common.cards.ItemCard +import snd.komelia.ui.common.cards.DEFAULT_CARD_MAX_LINES import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -90,6 +100,8 @@ fun AppearanceSettingsContent( onAccentColorChange: (Color?) -> Unit, useNewLibraryUI: Boolean, onUseNewLibraryUIChange: (Boolean) -> Unit, + cardLayoutBelow: Boolean, + onCardLayoutBelowChange: (Boolean) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(10.dp), @@ -118,6 +130,28 @@ fun AppearanceSettingsContent( HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Card layout", style = MaterialTheme.typography.bodyLarge) + Text( + "Show title and metadata below the thumbnail instead of on top", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = cardLayoutBelow, + onCheckedChange = onCardLayoutBelowChange, + modifier = Modifier.cursorForHand(), + ) + } + + HorizontalDivider() + DropdownChoiceMenu( label = { Text(strings.appTheme) }, selectedOption = LabeledEntry(currentTheme, strings.forAppTheme(currentTheme)), @@ -166,11 +200,40 @@ fun AppearanceSettingsContent( ) { Text("${cardWidth.value}") - Card( - Modifier - .width(cardWidth) - .aspectRatio(0.703f) - ) { + CompositionLocalProvider(LocalCardLayoutBelow provides cardLayoutBelow) { + ItemCard( + modifier = Modifier.width(cardWidth), + image = { + Box( + Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text("Thumbnail") + } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Series Example", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Book Title Example", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + ) } } } From 3e58997ab0f975c64fc57fccba6995b7f87c3d08 Mon Sep 17 00:00:00 2001 From: eserero Date: Mon, 2 Mar 2026 00:54:45 +0200 Subject: [PATCH 31/35] feat(ui): refine immersive oneshot screen and dropdown styling - Align immersive oneshot screen layout with the series screen (tabs for tags, collections, and read lists). - Ensure collections and read lists are initialized in OneshotViewModel. - Remove solid backgrounds and shadows from thumbnail count and filter dropdowns for a cleaner, transparent look. - Add thin borders to selected book view mode buttons. --- .../common/components/DropdownChoiceMenu.kt | 6 +- .../ui/common/components/Pagination.kt | 3 +- .../komelia/ui/oneshot/OneshotViewModel.kt | 2 + .../immersive/ImmersiveOneshotContent.kt | 152 ++++++++++++------ .../komelia/ui/series/view/BooksContent.kt | 24 ++- 5 files changed, 130 insertions(+), 57 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt index 6a424dc1..aefa6c18 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt @@ -179,7 +179,7 @@ private fun InputField( ) { val interactionSource = remember { MutableInteractionSource() } Surface( - shadowElevation = 1.dp, + shadowElevation = 0.dp, color = color, modifier = Modifier .cursorForHand() @@ -309,7 +309,7 @@ fun FilterDropdownChoice( contentPadding = PaddingValues(5.dp), label = label?.let { { Text(it) } }, inputFieldColor = MaterialTheme.colorScheme.surfaceVariant, - modifier = modifier.clip(RoundedCornerShape(5.dp)), + modifier = modifier, inputFieldModifier = Modifier.fillMaxWidth() ) } @@ -331,7 +331,7 @@ fun FilterDropdownMultiChoice( label = label?.let { { FilterLabelAndCount(label, selectedOptions.size) } }, placeholder = placeholder, inputFieldColor = MaterialTheme.colorScheme.surfaceVariant, - modifier = modifier.clip(RoundedCornerShape(5.dp)), + modifier = modifier, inputFieldModifier = Modifier.fillMaxWidth() ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Pagination.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Pagination.kt index 51cb0c5c..8f0480f6 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Pagination.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Pagination.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp @@ -139,7 +140,7 @@ fun PageSizeSelectionDropdown( ), onOptionChange = { onPageSizeChange(it.value) }, contentPadding = PaddingValues(5.dp), - inputFieldColor = MaterialTheme.colorScheme.surface, + inputFieldColor = Color.Transparent, inputFieldModifier = Modifier .widthIn(min = 70.dp) .clip(RoundedCornerShape(5.dp)) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt index a6344411..c6a1cf4b 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt @@ -107,6 +107,8 @@ class OneshotViewModel( }.launchIn(screenModelScope) startKomgaEventListener() + collectionsState.initialize() + readListsState.initialize() reloadFlow.onEach { reloadEventsEnabled.first { it } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt index 643bf893..d7bbb854 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -51,21 +52,18 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.util.lerp -import snd.komelia.ui.LocalAnimatedVisibilityScope -import snd.komelia.ui.LocalSharedTransitionScope import kotlinx.datetime.TimeZone import kotlinx.datetime.format import kotlinx.datetime.toLocalDateTime import snd.komelia.DefaultDateTimeFormats.localDateTimeFormat import snd.komelia.image.coil.SeriesDefaultThumbnailRequest -import snd.komelia.ui.LocalKomgaEvents -import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent -import snd.komga.client.sse.KomgaEvent.ThumbnailSeriesEvent -import kotlin.math.roundToInt import snd.komelia.komga.api.model.KomeliaBook -import snd.komelia.ui.book.BookInfoColumn +import snd.komelia.ui.LocalAnimatedVisibilityScope +import snd.komelia.ui.LocalKomgaEvents +import snd.komelia.ui.LocalSharedTransitionScope +import snd.komelia.ui.collection.SeriesCollectionsContent +import snd.komelia.ui.common.components.AppFilterChipDefaults import snd.komelia.ui.common.images.ThumbnailImage import snd.komelia.ui.common.immersive.ImmersiveDetailFab import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold @@ -75,15 +73,21 @@ import snd.komelia.ui.dialogs.ConfirmationDialog import snd.komelia.ui.dialogs.permissions.DownloadNotificationRequestDialog import snd.komelia.ui.library.SeriesScreenFilter import snd.komelia.ui.readlist.BookReadListsContent -import snd.komelia.ui.collection.SeriesCollectionsContent +import snd.komelia.ui.series.view.SeriesChipTags import snd.komelia.ui.series.view.SeriesDescriptionRow +import snd.komelia.ui.series.view.SeriesSummary import snd.komga.client.collection.KomgaCollection import snd.komga.client.library.KomgaLibrary import snd.komga.client.readlist.KomgaReadList import snd.komga.client.series.KomgaSeries +import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent +import snd.komga.client.sse.KomgaEvent.ThumbnailSeriesEvent +import kotlin.math.roundToInt private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) +private enum class OneshotImmersiveTab { TAGS, COLLECTIONS, READ_LISTS } + @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ImmersiveOneshotContent( @@ -147,6 +151,8 @@ fun ImmersiveOneshotContent( } } else Modifier + var currentTab by remember { mutableStateOf(OneshotImmersiveTab.TAGS) } + Box(modifier = Modifier.fillMaxSize()) { ImmersiveDetailScaffold( @@ -182,6 +188,8 @@ fun ImmersiveOneshotContent( onCollectionClick = onCollectionClick, onSeriesClick = onSeriesClick, cardWidth = cardWidth, + currentTab = currentTab, + onTabChange = { currentTab = it } ) } } @@ -281,6 +289,8 @@ private fun OneshotCardContent( onCollectionClick: (KomgaCollection) -> Unit, onSeriesClick: (KomgaSeries) -> Unit, cardWidth: Dp, + currentTab: OneshotImmersiveTab, + onTabChange: (OneshotImmersiveTab) -> Unit, ) { val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) val thumbnailTopGap = 20.dp @@ -395,57 +405,105 @@ private fun OneshotCardContent( } // Summary - if (book.metadata.summary.isNotBlank()) { - item { - Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = book.metadata.summary, - style = MaterialTheme.typography.bodyMedium, - ) - } + item(span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + SeriesSummary( + seriesSummary = series.metadata.summary, + bookSummary = book.metadata.summary, + bookSummaryNumber = book.metadata.number.toString(), + ) } } // Divider item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } - // Book metadata (authors, tags, links, file info, ISBN) + // Tab row item { - Box(Modifier.padding(horizontal = 16.dp)) { - BookInfoColumn( - publisher = series.metadata.publisher, - genres = series.metadata.genres, - authors = book.metadata.authors, - tags = book.metadata.tags, - links = book.metadata.links, - sizeInMiB = book.size, - mediaType = book.media.mediaType, - isbn = book.metadata.isbn, - fileUrl = book.url, - onFilterClick = onFilterClick, - ) - } + OneshotImmersiveTabRow( + currentTab = currentTab, + onTabChange = onTabChange, + showCollectionsTab = collections.isNotEmpty(), + showReadListsTab = readLists.isNotEmpty(), + ) } - // Reading lists - item(span = { GridItemSpan(maxLineSpan) }) { - BookReadListsContent( - readLists = readLists, - onReadListClick = onReadListClick, - onBookClick = onReadlistBookClick, - cardWidth = cardWidth, - ) + when (currentTab) { + OneshotImmersiveTab.TAGS -> { + item(span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + SeriesChipTags( + series = series, + onFilterClick = onFilterClick, + ) + } + } + } + + OneshotImmersiveTab.COLLECTIONS -> { + // Collections + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesCollectionsContent( + collections = collections, + onCollectionClick = onCollectionClick, + onSeriesClick = onSeriesClick, + cardWidth = cardWidth, + ) + } + } + + OneshotImmersiveTab.READ_LISTS -> { + // Reading lists + item(span = { GridItemSpan(maxLineSpan) }) { + BookReadListsContent( + readLists = readLists, + onReadListClick = onReadListClick, + onBookClick = onReadlistBookClick, + cardWidth = cardWidth, + ) + } + } } + } +} - // Collections - item(span = { GridItemSpan(maxLineSpan) }) { - SeriesCollectionsContent( - collections = collections, - onCollectionClick = onCollectionClick, - onSeriesClick = onSeriesClick, - cardWidth = cardWidth, +@Composable +private fun OneshotImmersiveTabRow( + currentTab: OneshotImmersiveTab, + onTabChange: (OneshotImmersiveTab) -> Unit, + showCollectionsTab: Boolean, + showReadListsTab: Boolean, +) { + val chipColors = AppFilterChipDefaults.filterChipColors() + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + FilterChip( + onClick = { onTabChange(OneshotImmersiveTab.TAGS) }, + selected = currentTab == OneshotImmersiveTab.TAGS, + label = { Text("Tags") }, + colors = chipColors, + border = null, ) + if (showCollectionsTab) { + FilterChip( + onClick = { onTabChange(OneshotImmersiveTab.COLLECTIONS) }, + selected = currentTab == OneshotImmersiveTab.COLLECTIONS, + label = { Text("Collections") }, + colors = chipColors, + border = null, + ) + } + if (showReadListsTab) { + FilterChip( + onClick = { onTabChange(OneshotImmersiveTab.READ_LISTS) }, + selected = currentTab == OneshotImmersiveTab.READ_LISTS, + label = { Text("Read Lists") }, + colors = chipColors, + border = null, + ) + } } + HorizontalDivider() } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/BooksContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/BooksContent.kt index 61ffe6bc..d1bf8e71 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/BooksContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/BooksContent.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.Animatable import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -47,6 +48,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -279,10 +281,15 @@ private fun BooksToolBar( Box( Modifier - .background( - if (booksLayout == LIST) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surface + .then( + if (booksLayout == LIST) Modifier.border( + Dp.Hairline, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(8.dp) + ) + else Modifier ) + .clip(RoundedCornerShape(8.dp)) .clickable { onBooksLayoutChange(LIST) } .cursorForHand() .padding(10.dp) @@ -295,10 +302,15 @@ private fun BooksToolBar( Box( Modifier - .background( - if (booksLayout == GRID) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surface + .then( + if (booksLayout == GRID) Modifier.border( + Dp.Hairline, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(8.dp) + ) + else Modifier ) + .clip(RoundedCornerShape(8.dp)) .clickable { onBooksLayoutChange(GRID) } .cursorForHand() .padding(10.dp) From 01e7fb9f482ab962b09b7b50835a1d3620bdf3b0 Mon Sep 17 00:00:00 2001 From: eserero Date: Mon, 2 Mar 2026 01:21:06 +0200 Subject: [PATCH 32/35] docs: update README with fork improvements and new features --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 4eb0034f..666a6fdf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,31 @@ # Komelia - Komga media client +## Fork Improvements +This is a fork of [Komelia](https://github.com/Gaysuist/Komelia) with several enhancements and new features: + +### Library UI Changes +* **Immersive Detail Screens:** New immersive layout for Book, Series, and Oneshot screens where the cover artwork integrates with the status bar using adaptive color gradients. +* **Shared-Element Transitions:** Cover images now animate and expand directly from the library list into the detail view when opened. +* **Material 3 Components:** Implementation of a floating pill-shaped navigation bar, squarish Material 3 chips, and updated FABs and menus. +* **Transparent Styling:** Selection dropdowns and filters now use a transparent, shadowless background. +* **"Below" Card Layout:** New card style that displays metadata in a single-line-per-segment format below the thumbnail. + +### Reader Changes +* **Adaptive Backgrounds:** New background system that samples colors from all four image edges to create blooming or gradient effects that fill the screen. +* **Kinetic Swipe:** Implementation of a kinetic "sticky" swipe system for paged reading with full RTL (Right-to-Left) support. +* **Panel Navigation:** Unified smooth pan-and-zoom controls and full-page context sequences for panel-to-panel navigation. +* **Improved Panel Detection:** Upgraded AI model (rf-detr-med) for more accurate automatic panel identification. +* **Spring Physics:** Density-aware spring physics for consistent gesture response across different screen types. +* **"Tap to Zoom" Toggle:** Ability to enable or disable single-tap zooming independently for paged and panel modes. + +### Settings +* **Accent Presets:** Selection of predefined accent color presets and an adaptive color system. +* **Card Layout:** Option to toggle between the standard and the new "Below" info card layout. +* **Background Configuration:** Detailed settings for adaptive background bloom, gradient styles, and corner blending. +* **Gesture Controls:** Toggles for tap-to-zoom and mode-specific navigation behaviors. + +--- + ### Downloads: - Latest prebuilt release is available at https://github.com/Snd-R/Komelia/releases From 9762d81222ad38d8f2a34ac19daced7813820f3f Mon Sep 17 00:00:00 2001 From: eserero Date: Mon, 2 Mar 2026 01:21:25 +0200 Subject: [PATCH 33/35] fix(ui): use stable window height for immersive collapsedOffset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoxWithConstraints.maxHeight shrinks by ~80dp when the Material NavigationBar disappears during the library→book transition. The previous fix added systemNavBarHeight back, but the instability was from the app nav bar, not the OS nav bar. Replace `maxHeight + rawNavBar` with `windowHeight − statusBar − navBar` using LocalWindowInfo.current.containerSize, which is invariant to whether the app NavigationBar is visible. Also add LocalRawNavBarHeight CompositionLocal (needed for the calculation) and remove the now-duplicate statusBarDp declaration. Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/snd/komelia/ui/CompositionLocals.kt | 1 + .../src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt | 7 ++++++- .../ui/common/immersive/ImmersiveDetailScaffold.kt | 10 ++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt index 0260a172..f7907503 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt @@ -46,6 +46,7 @@ val LocalAccentColor = compositionLocalOf { null } val LocalUseNewLibraryUI = compositionLocalOf { true } val LocalCardLayoutBelow = compositionLocalOf { false } val LocalRawStatusBarHeight = staticCompositionLocalOf { 0.dp } +val LocalRawNavBarHeight = staticCompositionLocalOf { 0.dp } @OptIn(ExperimentalSharedTransitionApi::class) val LocalSharedTransitionScope = compositionLocalOf { null } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index 0be5b5ef..b9241e6c 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars @@ -190,7 +191,11 @@ class MainScreen( navigator.lastItem is OneshotScreen val rawStatusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - CompositionLocalProvider(LocalRawStatusBarHeight provides rawStatusBarHeight) { + val rawNavBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + CompositionLocalProvider( + LocalRawStatusBarHeight provides rawStatusBarHeight, + LocalRawNavBarHeight provides rawNavBarHeight, + ) { Scaffold( containerColor = MaterialTheme.colorScheme.surface, bottomBar = { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index e8631c14..7be2b51f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -55,11 +55,13 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import snd.komelia.ui.LocalAnimatedVisibilityScope +import snd.komelia.ui.LocalRawNavBarHeight import snd.komelia.ui.LocalRawStatusBarHeight import snd.komelia.ui.LocalSharedTransitionScope import snd.komelia.ui.common.images.ThumbnailImage @@ -191,7 +193,12 @@ fun ImmersiveDetailScaffold( BoxWithConstraints(modifier = modifier.fillMaxSize().then(scaffoldEnterExitModifier)) { val screenHeight = maxHeight - val collapsedOffset = screenHeight * 0.65f + val statusBarDp = LocalRawStatusBarHeight.current + val navBarDp = LocalRawNavBarHeight.current + val windowHeightDp = with(density) { LocalWindowInfo.current.containerSize.height.toDp() } + // Use actual window height (invariant to app nav bar showing/hiding) for stable collapsedOffset. + val stableScreenHeight = windowHeightDp - statusBarDp - navBarDp + val collapsedOffset = stableScreenHeight * 0.65f val collapsedOffsetPx = with(density) { collapsedOffset.toPx() } // Use remember (not rememberSaveable) so pager pages don't restore stale saved state. @@ -286,7 +293,6 @@ fun ImmersiveDetailScaffold( } val topCornerRadiusDp = lerp(28f, 0f, expandFraction).dp - val statusBarDp = LocalRawStatusBarHeight.current val statusBarPx = with(density) { statusBarDp.toPx() } Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { From 9ce318e6feac2f8039804ff0c7d62b31dcadc946 Mon Sep 17 00:00:00 2001 From: eserero Date: Tue, 3 Mar 2026 02:37:41 +0200 Subject: [PATCH 34/35] feat(reader): modernize reader UI with floating slider and settings FAB - Update progress slider to May 2025 Material 3 spec (16dp track, animated thumb). - Implement floating slider design by removing solid backgrounds and adding semi-transparent tracks. - Replace top settings icon with a bottom-right Floating Action Button (FAB) using 'Tune' icon. - Enhance settings bottom sheet with standard M3 drag handle and scrim. - Integrate user-defined accent color across all reader and appearance sliders. --- .../komelia/ui/common/components/Slider.kt | 12 +- .../ui/reader/image/common/ProgressSlider.kt | 45 ++++--- .../settings/BottomSheetSettingsOverlay.kt | 111 +++++++++------- .../image/settings/CommonImageSettings.kt | 6 +- .../appearance/AppearanceSettingsContent.kt | 124 +++++------------- 5 files changed, 132 insertions(+), 166 deletions(-) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Slider.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Slider.kt index bcda1477..51bb73de 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Slider.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Slider.kt @@ -8,18 +8,18 @@ import androidx.compose.ui.graphics.Color object AppSliderDefaults { @Composable fun colors( - thumbColor: Color = MaterialTheme.colorScheme.tertiaryContainer, - activeTrackColor: Color = MaterialTheme.colorScheme.tertiary, - activeTickColor: Color = MaterialTheme.colorScheme.tertiaryContainer, - inactiveTrackColor: Color = MaterialTheme.colorScheme.surfaceBright, - inactiveTickColor: Color = MaterialTheme.colorScheme.surfaceVariant, + accentColor: Color? = null, + thumbColor: Color = accentColor ?: MaterialTheme.colorScheme.tertiaryContainer, + activeTrackColor: Color = accentColor ?: MaterialTheme.colorScheme.tertiary, + activeTickColor: Color = (accentColor ?: MaterialTheme.colorScheme.tertiaryContainer).copy(alpha = 0.5f), + inactiveTrackColor: Color = MaterialTheme.colorScheme.surfaceBright.copy(alpha = 0.5f), + inactiveTickColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), disabledThumbColor: Color = Color.Unspecified, disabledActiveTrackColor: Color = Color.Unspecified, disabledActiveTickColor: Color = Color.Unspecified, disabledInactiveTrackColor: Color = Color.Unspecified, disabledInactiveTickColor: Color = Color.Unspecified ) = SliderDefaults.colors( - thumbColor = thumbColor, activeTrackColor = activeTrackColor, activeTickColor = activeTickColor, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ProgressSlider.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ProgressSlider.kt index 050dbf2c..8384f57d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ProgressSlider.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ProgressSlider.kt @@ -37,6 +37,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalLayoutDirection @@ -56,6 +58,7 @@ import coil3.size.Size import coil3.size.SizeResolver import snd.komelia.image.ReaderImage import snd.komelia.image.coil.BookPageThumbnailRequest +import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.common.components.AppSliderDefaults import snd.komelia.ui.reader.image.PageMetadata import kotlin.math.roundToInt @@ -98,6 +101,8 @@ fun PageSpreadProgressSlider( Modifier .fillMaxWidth() .hoverable(interactionSource) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 10.dp) ) ) { if (show || isHovered.value) { @@ -107,16 +112,10 @@ fun PageSpreadProgressSlider( pageSpreads = pageSpreads, currentSpreadIndex = currentSpreadIndex, onPageNumberChange = onPageNumberChange, - layoutDirection = layoutDirection + layoutDirection = layoutDirection, + interactionSource = interactionSource, ) } - - Spacer( - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .windowInsetsPadding(WindowInsets.navigationBars) - ) } } } @@ -129,6 +128,7 @@ private fun Slider( currentSpreadIndex: Int, onPageNumberChange: (Int) -> Unit, layoutDirection: LayoutDirection, + interactionSource: MutableInteractionSource, ) { var currentPos by remember(currentSpreadIndex) { mutableStateOf(currentSpreadIndex) } val currentSpread = remember(pageSpreads, currentPos) { pageSpreads.getOrElse(currentPos) { pageSpreads.last() } } @@ -143,6 +143,7 @@ private fun Slider( var showPreview by remember { mutableStateOf(false) } val sliderValue by derivedStateOf { currentPos.toFloat() } + val accentColor = LocalAccentColor.current val sliderState = rememberSliderState( value = sliderValue, @@ -159,7 +160,7 @@ private fun Slider( ) Layout(content = { - if ( showPreview) { + if (showPreview) { Row { for (pageMetadata in currentSpread) { BookPageThumbnail( @@ -170,28 +171,38 @@ private fun Slider( } } else Spacer(Modifier) + val labelBackground = accentColor?.copy(alpha = 0.8f) ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f) + val onLabelColor = if (accentColor != null) { + if (accentColor.luminance() > 0.5f) Color.Black else Color.White + } else MaterialTheme.colorScheme.onSurfaceVariant + Text( label, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary, + color = onLabelColor, modifier = Modifier .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(4.dp) + color = labelBackground, + shape = RoundedCornerShape(20.dp) ) - .border(BorderStroke(1.dp, MaterialTheme.colorScheme.surface)) - .padding(4.dp) + .padding(horizontal = 12.dp, vertical = 4.dp) .defaultMinSize(minWidth = 40.dp) ) Slider( state = sliderState, - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant), - colors = AppSliderDefaults.colors(), + colors = AppSliderDefaults.colors(accentColor = accentColor), track = { state -> SliderDefaults.Track( sliderState = state, - colors = AppSliderDefaults.colors(), + colors = AppSliderDefaults.colors(accentColor = accentColor), + modifier = Modifier.height(16.dp) + ) + }, + thumb = { state -> + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = AppSliderDefaults.colors(accentColor = accentColor), ) } ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt index 5c3d8794..5955e905 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars @@ -27,11 +29,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -52,6 +55,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.style.TextOverflow @@ -70,6 +74,7 @@ import snd.komelia.settings.model.ReaderType import snd.komelia.settings.model.ReaderType.CONTINUOUS import snd.komelia.settings.model.ReaderType.PAGED import snd.komelia.settings.model.ReaderType.PANELS +import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalStrings import snd.komelia.ui.LocalWindowWidth import snd.komelia.ui.common.components.AppSliderDefaults @@ -119,54 +124,65 @@ fun BottomSheetSettingsOverlay( ) { val windowWidth = LocalWindowWidth.current + val accentColor = LocalAccentColor.current var showSettingsDialog by remember { mutableStateOf(false) } - Row( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceVariant) - .fillMaxWidth() - .windowInsetsPadding( - WindowInsets.statusBars - .add(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) - ), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = onBackPress, - modifier = Modifier.size(46.dp) - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, null) - } - book?.let { - Column( - Modifier.weight(1f) - .padding(horizontal = 10.dp) + Box(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxWidth() + .windowInsetsPadding( + WindowInsets.statusBars + .add(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) + ), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBackPress, + modifier = Modifier.size(46.dp) ) { - val titleStyle = - if (windowWidth == COMPACT) MaterialTheme.typography.titleMedium - else MaterialTheme.typography.titleLarge - - Text( - it.seriesTitle, - maxLines = 1, - style = titleStyle, - overflow = TextOverflow.Ellipsis - ) - Text( - it.metadata.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE) - ) + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + + book?.let { + Column( + Modifier.weight(1f) + .padding(horizontal = 10.dp) + ) { + val titleStyle = + if (windowWidth == COMPACT) MaterialTheme.typography.titleMedium + else MaterialTheme.typography.titleLarge + + Text( + it.seriesTitle, + maxLines = 1, + style = titleStyle, + overflow = TextOverflow.Ellipsis + ) + Text( + it.metadata.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE) + ) + } } } - FilledIconButton( - onClick = { showSettingsDialog = true }, -// shape = RoundedCornerShape(13.dp), - modifier = Modifier.size(46.dp) + FloatingActionButton( + onClick = { showSettingsDialog = true }, + containerColor = accentColor ?: MaterialTheme.colorScheme.primaryContainer, + contentColor = if (accentColor != null) { + if (accentColor.luminance() > 0.5f) Color.Black else Color.White + } else MaterialTheme.colorScheme.onPrimaryContainer, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 80.dp, end = 16.dp) ) { - Icon(Icons.Default.Settings, null) + Icon(Icons.Rounded.Tune, null) } } @@ -178,8 +194,6 @@ fun BottomSheetSettingsOverlay( ModalBottomSheet( onDismissRequest = { showSettingsDialog = false }, sheetState = sheetState, - dragHandle = {}, - scrimColor = Color.Transparent, containerColor = MaterialTheme.colorScheme.surface, ) { var selectedTab by remember { mutableStateOf(0) } @@ -468,6 +482,7 @@ private fun ContinuousModeSettings( ) { val strings = LocalStrings.current.continuousReader val windowWidth = LocalWindowWidth.current + val accentColor = LocalAccentColor.current Column { val readingDirection = state.readingDirection.collectAsState().value Text(strings.readingDirection) @@ -503,7 +518,7 @@ private fun ContinuousModeSettings( onValueChange = state::onSidePaddingChange, steps = 15, valueRange = 0f..0.4f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) } @@ -519,7 +534,7 @@ private fun ContinuousModeSettings( onValueChange = { state.onPageSpacingChange(it.roundToInt()) }, steps = 24, valueRange = 0f..250f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) else -> Slider( @@ -527,7 +542,7 @@ private fun ContinuousModeSettings( onValueChange = { state.onPageSpacingChange(it.roundToInt()) }, steps = 49, valueRange = 0f..500f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/CommonImageSettings.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/CommonImageSettings.kt index b8301fd6..4c245733 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/CommonImageSettings.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/CommonImageSettings.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import snd.komelia.settings.model.ReaderFlashColor +import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalPlatform import snd.komelia.ui.LocalStrings import snd.komelia.ui.common.components.AppSliderDefaults @@ -61,6 +62,7 @@ fun CommonImageSettings( val strings = LocalStrings.current val readerStrings = strings.reader val platform = LocalPlatform.current + val accentColor = LocalAccentColor.current Column(modifier = modifier) { SwitchWithLabel( checked = stretchToFit, @@ -128,7 +130,7 @@ fun CommonImageSettings( onValueChange = { onFlashDurationChange(it.roundToLong()) }, steps = 13, valueRange = 100f..1500f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) } @@ -146,7 +148,7 @@ fun CommonImageSettings( onValueChange = { onFlashEveryNPagesChange(it.roundToInt()) }, steps = 10, valueRange = 1f..10f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt index 3ccd4106..00ec20df 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt @@ -2,14 +2,10 @@ package snd.komelia.ui.settings.appearance import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -18,56 +14,30 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import snd.komelia.ui.LocalCardLayoutBelow -import androidx.compose.runtime.CompositionLocalProvider -import snd.komelia.ui.common.cards.ItemCard -import snd.komelia.ui.common.cards.DEFAULT_CARD_MAX_LINES import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import snd.komelia.settings.model.AppTheme +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalStrings +import snd.komelia.ui.common.cards.ItemCard import snd.komelia.ui.common.components.AppSliderDefaults import snd.komelia.ui.common.components.DropdownChoiceMenu import snd.komelia.ui.common.components.LabeledEntry import snd.komelia.ui.platform.cursorForHand import kotlin.math.roundToInt -private val navBarPresets: List> = listOf( - null to "Auto", - Color(0xFF800020.toInt()) to "Burgundy", - Color(0xFFE57373.toInt()) to "Muted Red", - Color(0xFF5783D4.toInt()) to "Secondary Blue", - Color(0xFF201F23.toInt()) to "Toolbar (Dark)", - Color(0xFFE1E1E1.toInt()) to "Toolbar (Light)", - Color(0xFF2D3436.toInt()) to "Charcoal", - Color(0xFF1A1A2E.toInt()) to "Navy", - Color(0xFF0D3B46.toInt()) to "D.Teal", - Color(0xFF1B4332.toInt()) to "Forest", - Color(0xFF3D1A78.toInt()) to "Violet", - Color(0xFF3B82F6.toInt()) to "Blue", - Color(0xFF14B8A6.toInt()) to "Teal", - Color(0xFF8B5CF6.toInt()) to "Purple", - Color(0xFFEC4899.toInt()) to "Pink", - Color(0xFFF97316.toInt()) to "Orange", - Color(0xFF22C55E.toInt()) to "Green", -) - private val accentPresets: List> = listOf( null to "Auto", Color(0xFF800020.toInt()) to "Burgundy", @@ -94,8 +64,6 @@ fun AppearanceSettingsContent( onCardWidthChange: (Dp) -> Unit, currentTheme: AppTheme, onThemeChange: (AppTheme) -> Unit, - navBarColor: Color?, - onNavBarColorChange: (Color?) -> Unit, accentColor: Color?, onAccentColorChange: (Color?) -> Unit, useNewLibraryUI: Boolean, @@ -163,20 +131,15 @@ fun AppearanceSettingsContent( if (useNewLibraryUI) { HorizontalDivider() - Text("Nav Bar Color", modifier = Modifier.padding(10.dp)) - ColorSwatchRow( - presets = navBarPresets, - selectedColor = navBarColor, - onColorSelected = onNavBarColorChange, - ) - - HorizontalDivider() - - Text("Accent Color (chips & tabs)", modifier = Modifier.padding(10.dp)) - ColorSwatchRow( - presets = accentPresets, - selectedColor = accentColor, - onColorSelected = onAccentColorChange, + DropdownChoiceMenu( + label = { Text("Accent Color (chips & tabs)") }, + selectedOption = accentPresets.find { it.first == accentColor } + ?.let { LabeledEntry(it.first, it.second) }, + options = accentPresets.map { LabeledEntry(it.first, it.second) }, + onOptionChange = { onAccentColorChange(it.value) }, + inputFieldModifier = Modifier.widthIn(min = 250.dp), + selectedOptionContent = { ColorLabel(it) }, + optionContent = { ColorLabel(it) } ) } @@ -188,7 +151,7 @@ fun AppearanceSettingsContent( onValueChange = { onCardWidthChange(it.roundToInt().dp) }, steps = 24, valueRange = 100f..350f, - colors = AppSliderDefaults.colors(), + colors = AppSliderDefaults.colors(accentColor = accentColor), modifier = Modifier.cursorForHand().padding(end = 20.dp), ) Column( @@ -239,60 +202,35 @@ fun AppearanceSettingsContent( } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun ColorSwatchRow( - presets: List>, - selectedColor: Color?, - onColorSelected: (Color?) -> Unit, -) { - FlowRow( - modifier = Modifier.padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), +private fun ColorLabel(entry: LabeledEntry) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - for ((color, label) in presets) { - ColorSwatch( - color = color, - label = label, - isSelected = color == selectedColor, - onClick = { onColorSelected(color) }, - ) - } - } -} - -@Composable -private fun ColorSwatch( - color: Color?, - label: String, - isSelected: Boolean, - onClick: () -> Unit, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - val swatchColor = color ?: MaterialTheme.colorScheme.surfaceVariant - val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent + val swatchColor = entry.value ?: MaterialTheme.colorScheme.surfaceVariant Box( modifier = Modifier - .size(36.dp) + .size(20.dp) .clip(CircleShape) .background(swatchColor) - .border(2.dp, borderColor, CircleShape) - .clickable { onClick() } - .cursorForHand(), + .then( + if (entry.value == null) Modifier.border( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant, + CircleShape + ) + else Modifier + ) ) { - if (color == null) { + if (entry.value == null) { Text( "A", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.Center), + modifier = Modifier.align(Alignment.Center) ) } } - Text(label, style = MaterialTheme.typography.labelSmall) + Text(entry.label) } } From f4f30f907b205429626d0118f7b0dc15f9581152 Mon Sep 17 00:00:00 2001 From: eserero Date: Tue, 3 Mar 2026 09:33:07 +0200 Subject: [PATCH 35/35] feat(ui): refine shared transitions, animated menus, and appearance settings - Introduce AnimatedDropdownMenu for M3-style enter/exit animations. - Replace standard DropdownMenu with AnimatedDropdownMenu in all action menus. - Enhance shared transitions in immersive screens (Book, Oneshot, DetailScaffold) with better overlay rendering and timing. - Refine navigation logic to prevent redundant screen replacements. - Modernize appearance settings: replace nav bar color with accent color and use DropdownChoiceMenu for accent presets. - Fix immersive screen bottom bar visibility and styling for New UI. --- .../kotlin/snd/komelia/ui/MainScreen.kt | 53 +++++++----- .../ui/book/immersive/ImmersiveBookContent.kt | 16 ++-- .../common/components/AnimatedDropdownMenu.kt | 85 +++++++++++++++++++ .../common/components/DropdownChoiceMenu.kt | 18 ++-- .../immersive/ImmersiveDetailScaffold.kt | 16 ++-- .../ui/common/menus/BookActionsMenu.kt | 4 +- .../ui/common/menus/CollectionActionsMenu.kt | 4 +- .../ui/common/menus/LibraryActionsMenu.kt | 4 +- .../ui/common/menus/OneshotActionsMenu.kt | 4 +- .../ui/common/menus/ReadListActionsMenu.kt | 4 +- .../ui/common/menus/SeriesActionsMenu.kt | 4 +- .../immersive/ImmersiveOneshotContent.kt | 16 ++-- .../settings/appearance/AppSettingsScreen.kt | 12 ++- .../appearance/AppSettingsViewModel.kt | 9 +- 14 files changed, 176 insertions(+), 73 deletions(-) create mode 100644 komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/AnimatedDropdownMenu.kt diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index b9241e6c..c2259b47 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -189,7 +189,6 @@ class MainScreen( val isImmersiveScreen = navigator.lastItem is SeriesScreen || navigator.lastItem is BookScreen || navigator.lastItem is OneshotScreen - val rawStatusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val rawNavBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() CompositionLocalProvider( @@ -199,19 +198,19 @@ class MainScreen( Scaffold( containerColor = MaterialTheme.colorScheme.surface, bottomBar = { - if (!isImmersiveScreen) { - if (useNewLibraryUI) { - AppNavigationBar( - navigator = navigator, - toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } } - ) - } else { - StandardBottomNavigationBar( - navigator = navigator, - toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, - modifier = Modifier - ) - } + if (useNewLibraryUI) { + AppNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + containerColor = if (isImmersiveScreen) MaterialTheme.colorScheme.surfaceVariant + else LocalNavBarColor.current ?: MaterialTheme.colorScheme.surface + ) + } else { + StandardBottomNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + modifier = Modifier + ) } } ) { paddingValues -> @@ -254,8 +253,8 @@ class MainScreen( private fun AppNavigationBar( navigator: Navigator, toggleLibrariesDrawer: () -> Unit, + containerColor: Color = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surface, ) { - val containerColor = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surface val accentColor = LocalAccentColor.current val itemColors = if (accentColor != null) { NavigationBarItemDefaults.colors( @@ -405,12 +404,18 @@ class MainScreen( if (width != FULL) coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, onLibrariesClick = { - navigator.replaceAll(LibraryScreen()) + val current = navigator.lastItem + if (current !is LibraryScreen || current.libraryId != null) { + navigator.replaceAll(LibraryScreen()) + } if (width != FULL) coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, - onLibraryClick = { - navigator.replaceAll(LibraryScreen(it)) + onLibraryClick = { libraryId -> + val current = navigator.lastItem + if (current !is LibraryScreen || current.libraryId != libraryId) { + navigator.replaceAll(LibraryScreen(libraryId)) + } if (width != FULL) coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, onSettingsClick = { navigator.parent!!.push(SettingsScreen()) }, @@ -429,12 +434,18 @@ class MainScreen( libraries = vm.libraries.collectAsState().value, libraryActions = vm.getLibraryActions(), onLibrariesClick = { - navigator.replaceAll(LibraryScreen()) + val current = navigator.lastItem + if (current !is LibraryScreen || current.libraryId != null) { + navigator.replaceAll(LibraryScreen()) + } coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, - onLibraryClick = { - navigator.replaceAll(LibraryScreen(it)) + onLibraryClick = { libraryId -> + val current = navigator.lastItem + if (current !is LibraryScreen || current.libraryId != libraryId) { + navigator.replaceAll(LibraryScreen(libraryId)) + } coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt index 815ed907..3ee503b4 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -169,12 +169,16 @@ fun ImmersiveBookContent( } } else Modifier - val uiOverlayModifier = if (animatedVisibilityScope != null) { - with(animatedVisibilityScope) { - Modifier.animateEnterExit( - enter = fadeIn(tween(durationMillis = 150, delayMillis = 450)), - exit = fadeOut(tween(durationMillis = 100)) - ) + val uiOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 0.75f) + .animateEnterExit( + enter = fadeIn(tween(durationMillis = 500)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } } } else Modifier diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/AnimatedDropdownMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/AnimatedDropdownMenu.kt new file mode 100644 index 00000000..a3a2832b --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/AnimatedDropdownMenu.kt @@ -0,0 +1,85 @@ +package snd.komelia.ui.common.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.DropdownMenu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first + +/** + * A [DropdownMenu] wrapper that applies M3-style fade + scale enter/exit animations. + * Scale origin defaults to top-right (1f, 0f), matching a trailing 3-dots button. + */ +@Composable +fun AnimatedDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 0.dp), + transformOrigin: TransformOrigin = TransformOrigin(1f, 0f), + content: @Composable ColumnScope.() -> Unit, +) { + val transitionState = remember { MutableTransitionState(false) } + var popupVisible by remember { mutableStateOf(false) } + + LaunchedEffect(expanded) { + if (expanded) { + popupVisible = true + transitionState.targetState = true + } else { + transitionState.targetState = false + // Wait for exit animation to finish before removing the popup + snapshotFlow { transitionState.isIdle } + .filter { it } + .first() + popupVisible = false + } + } + + if (popupVisible) { + DropdownMenu( + expanded = true, + onDismissRequest = onDismissRequest, + offset = offset, + scrollState = rememberScrollState(), + ) { + AnimatedVisibility( + visibleState = transitionState, + enter = fadeIn(tween(200, easing = FastOutSlowInEasing)) + + scaleIn( + initialScale = 0.85f, + transformOrigin = transformOrigin, + animationSpec = tween(200, easing = FastOutSlowInEasing), + ), + exit = fadeOut(tween(120)) + + scaleOut( + targetScale = 0.85f, + transformOrigin = transformOrigin, + animationSpec = tween(120), + ), + ) { + Column(modifier = modifier) { content() } + } + } + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt index aefa6c18..77df3fa2 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt @@ -76,7 +76,9 @@ fun DropdownChoiceMenu( modifier: Modifier = Modifier, label: @Composable (() -> Unit)? = null, inputFieldColor: Color = MaterialTheme.colorScheme.surfaceVariant, - contentPadding: PaddingValues = PaddingValues(10.dp) + contentPadding: PaddingValues = PaddingValues(10.dp), + selectedOptionContent: @Composable (LabeledEntry) -> Unit = { Text(it.label, maxLines = 1) }, + optionContent: @Composable (LabeledEntry) -> Unit = { Text(it.label) } ) { var isExpanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( @@ -85,7 +87,7 @@ fun DropdownChoiceMenu( onExpandedChange = { isExpanded = it }, ) { InputField( - value = selectedOption?.label ?: "", + content = selectedOption?.let { { selectedOptionContent(it) } } ?: {}, modifier = Modifier .menuAnchor(PrimaryNotEditable) .clip(RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp)) @@ -106,7 +108,7 @@ fun DropdownChoiceMenu( options.forEach { DropdownMenuItem( - text = { Text(it.label) }, + text = { optionContent(it) }, onClick = { onOptionChange(it) isExpanded = false @@ -137,7 +139,7 @@ fun DropdownMultiChoiceMenu( onExpandedChange = { isExpanded = it }, ) { InputField( - value = selectedOptions.joinToString { it.label }.ifBlank { placeholder ?: "Any" }, + content = { Text(selectedOptions.joinToString { it.label }.ifBlank { placeholder ?: "Any" }, maxLines = 1) }, modifier = Modifier .menuAnchor(PrimaryNotEditable) .clip(RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp)) @@ -170,7 +172,7 @@ fun DropdownMultiChoiceMenu( @Composable private fun InputField( - value: String, + content: @Composable () -> Unit, modifier: Modifier, label: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit), @@ -199,7 +201,7 @@ private fun InputField( CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.labelMedium) { label?.let { it() } } - Text(value, maxLines = 1) + content() } Spacer(Modifier.weight(1f)) @@ -234,7 +236,7 @@ fun DropdownChoiceMenuWithSearch( onExpandedChange = { isExpanded = it }, ) { InputField( - value = selectedOptions.joinToString { it.label }.ifBlank { placeholder ?: "Any" }, + content = { Text(selectedOptions.joinToString { it.label }.ifBlank { placeholder ?: "Any" }, maxLines = 1) }, modifier = Modifier .menuAnchor(PrimaryNotEditable) .then(textFieldModifier), @@ -433,7 +435,7 @@ fun TagFiltersDropdownMenu( onExpandedChange = { isExpanded = it }, ) { InputField( - value = inputValue, + content = { Text(inputValue, maxLines = 1) }, modifier = Modifier .menuAnchor(PrimaryNotEditable) .then(inputFieldModifier), diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt index 7be2b51f..8313d9ca 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -168,12 +168,16 @@ fun ImmersiveDetailScaffold( } } else Modifier - val uiEnterExitModifier = if (animatedVisibilityScope != null) { - with(animatedVisibilityScope) { - Modifier.animateEnterExit( - enter = fadeIn(tween(durationMillis = 150, delayMillis = 450)), - exit = fadeOut(tween(durationMillis = 100)) - ) + val uiEnterExitModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 0.75f) + .animateEnterExit( + enter = fadeIn(tween(durationMillis = 500)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } } } else Modifier diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt index 13b0acbf..d29fa0a1 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt @@ -10,8 +10,8 @@ import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults @@ -118,7 +118,7 @@ fun BookActionsMenu( } val showDropdown = derivedStateOf { expanded && !showDeleteDialog && !showEditDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt index 78347a9f..58c49f99 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt @@ -3,8 +3,8 @@ package snd.komelia.ui.common.menus import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults @@ -54,7 +54,7 @@ fun CollectionActionsMenu( } val showDropdown = derivedStateOf { expanded && !showDeleteDialog && !showEditDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt index 286febd6..8e56cb0f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt @@ -4,8 +4,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.DeleteSweep -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -114,7 +114,7 @@ fun LibraryActionsMenu( val isAdmin = LocalKomgaState.current.authenticatedUser.collectAsState().value?.roleAdmin() ?: true val isOffline = LocalOfflineMode.current.collectAsState().value - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + AnimatedDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { if (isAdmin && !isOffline) { DropdownMenuItem( text = { Text("Scan library files", style = MaterialTheme.typography.labelLarge) }, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt index 8cc7f264..7dced429 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt @@ -8,8 +8,8 @@ import androidx.compose.material.icons.rounded.Label import androidx.compose.material.icons.rounded.LabelOff import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults @@ -126,7 +126,7 @@ fun OneshotActionsMenu( !showAddToReadListDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt index 37cfa09c..068caa77 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt @@ -3,8 +3,8 @@ package snd.komelia.ui.common.menus import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults @@ -53,7 +53,7 @@ fun ReadListActionsMenu( } val showDropdown = derivedStateOf { expanded && !showDeleteDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt index 8dfd241a..f6b4093e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt @@ -10,8 +10,8 @@ import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults @@ -149,7 +149,7 @@ fun SeriesActionsMenu( !showEditDialog && !showAddToCollectionDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt index d7bbb854..06367b63 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -142,12 +142,16 @@ fun ImmersiveOneshotContent( } } else Modifier - val uiOverlayModifier = if (animatedVisibilityScope != null) { - with(animatedVisibilityScope) { - Modifier.animateEnterExit( - enter = fadeIn(tween(durationMillis = 150, delayMillis = 450)), - exit = fadeOut(tween(durationMillis = 100)) - ) + val uiOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 0.75f) + .animateEnterExit( + enter = fadeIn(tween(durationMillis = 500)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } } } else Modifier diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt index 9445236f..6f04ab1d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt @@ -29,15 +29,13 @@ class AppSettingsScreen : Screen { onCardWidthChange = vm::onCardWidthChange, currentTheme = vm.currentTheme, onThemeChange = vm::onAppThemeChange, - navBarColor = vm.navBarColor, - onNavBarColorChange = vm::onNavBarColorChange, accentColor = vm.accentColor, onAccentColorChange = vm::onAccentColorChange, - useNewLibraryUI = vm.useNewLibraryUI, - onUseNewLibraryUIChange = vm::onUseNewLibraryUIChange, - cardLayoutBelow = vm.cardLayoutBelow, - onCardLayoutBelowChange = vm::onCardLayoutBelowChange, - ) + useNewLibraryUI = vm.useNewLibraryUI, + onUseNewLibraryUIChange = vm::onUseNewLibraryUIChange, + cardLayoutBelow = vm.cardLayoutBelow, + onCardLayoutBelowChange = vm::onCardLayoutBelowChange, + ) } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt index 07480fb9..bb976078 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt @@ -22,7 +22,6 @@ class AppSettingsViewModel( ) : StateScreenModel>(LoadState.Uninitialized) { var cardWidth by mutableStateOf(defaultCardWidth.dp) var currentTheme by mutableStateOf(AppTheme.DARK) - var navBarColor by mutableStateOf(null) var accentColor by mutableStateOf(null) var useNewLibraryUI by mutableStateOf(true) var cardLayoutBelow by mutableStateOf(false) @@ -32,10 +31,11 @@ class AppSettingsViewModel( mutableState.value = LoadState.Loading cardWidth = settingsRepository.getCardWidth().map { it.dp }.first() currentTheme = settingsRepository.getAppTheme().first() - navBarColor = settingsRepository.getNavBarColor().first()?.let { Color(it.toInt()) } accentColor = settingsRepository.getAccentColor().first()?.let { Color(it.toInt()) } useNewLibraryUI = settingsRepository.getUseNewLibraryUI().first() cardLayoutBelow = settingsRepository.getCardLayoutBelow().first() + + settingsRepository.putNavBarColor(null) mutableState.value = LoadState.Success(Unit) } @@ -49,11 +49,6 @@ class AppSettingsViewModel( screenModelScope.launch { settingsRepository.putAppTheme(theme) } } - fun onNavBarColorChange(color: Color?) { - this.navBarColor = color - screenModelScope.launch { settingsRepository.putNavBarColor(color?.toArgb()?.toLong()) } - } - fun onAccentColorChange(color: Color?) { this.accentColor = color screenModelScope.launch { settingsRepository.putAccentColor(color?.toArgb()?.toLong()) }