From 97fe3b066cd1212d969cc7d961f854d5eec09193 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Tue, 7 Jul 2026 22:52:03 -0400 Subject: [PATCH 1/8] Fix UI feedback and state gaps across screens - Restore status/nav bar icon appearance when leaving the reader - Use app background for reader empty/loading/error panels (was always black) - Surface add-from-URL success and failure as snackbars in Library - Show retryable offline error in Explore instead of generic empty state - Add Browse sources / Import file buttons to the empty-library state - Show 'Start reading' for never-opened titles instead of 'Resume Chapter 0' - Report Settings library size as titles and entries - Accessibility: merge AI switch row semantics, 48dp touch targets, chapter filter icon descriptions, darker Paper theme text (>=7:1 contrast) --- app/detekt-baseline.xml | 6 +- .../easyreader/data/model/ReaderTheme.kt | 4 +- .../ui/components/ChapterListSheet.kt | 10 +- .../ui/components/LibraryItemCard.kt | 2 +- .../ui/screens/explore/ExploreGridSection.kt | 18 +++- .../screens/library/LibraryDrawerContent.kt | 12 ++- .../ui/screens/library/LibraryNovelHelpers.kt | 9 ++ .../ui/screens/library/LibraryScreen.kt | 32 ++++++- .../library/LibraryScreenListSections.kt | 15 ++- .../ui/screens/reader/ReaderScreen.kt | 26 +++-- .../ui/screens/settings/SettingsScreen.kt | 33 +++++-- .../ui/viewmodel/ExploreViewModel.kt | 95 +++++++++++++++---- .../ui/viewmodel/LibraryViewModel.kt | 70 ++++++++++---- .../ui/screens/LibraryNovelHelpersTest.kt | 11 +++ .../ui/viewmodel/ExploreViewModelTest.kt | 17 ++++ .../ui/viewmodel/LibraryViewModelTest.kt | 48 ++++++++++ 16 files changed, 326 insertions(+), 82 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index c073f6ca..50babb3e 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -78,7 +78,7 @@ FunctionNaming:LibraryItemCard.kt$@Composable fun LibraryItemCardPreview() FunctionNaming:LibraryItemCard.kt$@OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryItemCard( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onNewTagClick: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onResetProgress: (() -> Unit)? = null, onMarkFinished: (() -> Unit)? = null ) FunctionNaming:LibraryScreen.kt$@Composable private fun AddNovelSection( urlInput: String, onUrlChange: (String) -> Unit, onAddClick: () -> Unit, onOpenPdfClick: () -> Unit ): Unit - FunctionNaming:LibraryScreen.kt$@Composable private fun EmptyLibraryState( isSearchEmpty: Boolean = false, isFilteredEmpty: Boolean = false, onClearSearch: () -> Unit = {}, query: String = "" ) + FunctionNaming:LibraryScreen.kt$@Composable private fun EmptyLibraryState( isFilteredEmpty: Boolean = false, onClearSearch: () -> Unit = {}, onBrowseSources: () -> Unit = {}, onImportFile: () -> Unit = {}, query: String = "" ) FunctionNaming:LibraryScreen.kt$@Composable private fun LibraryStatusRow( query: String, totalCount: Int, visibleCount: Int, isSelectionMode: Boolean, selectedCount: Int, onSelectionClick: () -> Unit ): Unit FunctionNaming:LibraryScreen.kt$@Composable private fun ReadingStatusFilterRow( selected: SeriesReadingStatus, counts: Map<SeriesReadingStatus, Int>, onSelect: (SeriesReadingStatus) -> Unit ) FunctionNaming:LibraryScreen.kt$@Composable private fun SearchLibraryField( query: String, onQueryChange: (String) -> Unit ): Unit @@ -427,7 +427,6 @@ MagicNumber:ReaderTheme.kt$ReaderTheme.LIGHT$0xFFFFFFFF MagicNumber:ReaderTheme.kt$ReaderTheme.OLED$0xFF000000 MagicNumber:ReaderTheme.kt$ReaderTheme.OLED$0xFFFFFFFF - MagicNumber:ReaderTheme.kt$ReaderTheme.PAPER$0xFF657B83 MagicNumber:ReaderTheme.kt$ReaderTheme.PAPER$0xFFFDF6E3 MagicNumber:ReaderTheme.kt$ReaderTheme.SEPIA$0xFF5B4636 MagicNumber:ReaderTheme.kt$ReaderTheme.SEPIA$0xFFF4ECD8 @@ -532,8 +531,6 @@ MaxLineLength:ExploreScreen.kt$"${uiState.selectedSource} + ${uiState.selectedTags.size} genre${if (uiState.selectedTags.size > 1) "s" else ""}" MaxLineLength:ExploreViewModel.kt$ExploreViewModel$exploreRepository.getNovels(_uiState.value.browseMode, page, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) MaxLineLength:ExploreViewModel.kt$ExploreViewModel$val novels = exploreRepository.getNovels(_uiState.value.browseMode, 1, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) - MaxLineLength:ExploreViewModel.kt$ExploreViewModel$val novels = exploreRepository.getNovels(_uiState.value.browseMode, 1, _uiState.value.selectedSource, emptyList()) - MaxLineLength:ExploreViewModel.kt$ExploreViewModel$val novels = exploreRepository.getNovels(_uiState.value.browseMode, 1, _uiState.value.selectedSource, newTags.toList()) MaxLineLength:HtmlParser.kt$HtmlParser$Regex(""""url"\s*(?::|,)\s*\[?\s*\d*\s*,?\s*"(https?://[^"]+\.(?:webp|jpg|jpeg|png|gif))""""", RegexOption.IGNORE_CASE) MaxLineLength:HtmlParser.kt$HtmlParser$document.select("img[alt*='credit'], img[alt*='recommend'], img[src*='credit'], img[src*='recommend'], img[alt*='ei0qg'], img[title*='ei0qg']").remove() MaxLineLength:HtmlParser.kt$HtmlParser$if (p.isEmpty() || p.matches(DIGIT_ONLY_REGEX) || CHAPTER_CLEANUP_PATTERN.containsMatchIn(p)) return@filter false @@ -748,7 +745,6 @@ TooManyFunctions:WebOfflineChapterStore.kt$WebOfflineChapterStore UnusedParameter:AsuraScansSource.kt$AsuraScansSource$seriesUrl: String UnusedParameter:ComposeUtils.kt$height: Int - UnusedParameter:LibraryScreen.kt$isSearchEmpty: Boolean = false UnusedParameter:NovelFireSource.kt$NovelFireSource$chaptersUrl: String UnusedParameter:SummaryService.kt$SummaryService$selectedContent: String UnusedParameter:TextFormattingPipeline.kt$TextFormattingPipeline$original: String diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/ReaderTheme.kt b/app/src/main/java/io/aatricks/easyreader/data/model/ReaderTheme.kt index a9e4d4f1..0e3cc961 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/model/ReaderTheme.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/model/ReaderTheme.kt @@ -4,6 +4,8 @@ import androidx.compose.ui.graphics.Color import kotlinx.serialization.Serializable +private const val SOLARIZED_PAPER_TEXT = 0xFF46555E + /** * Themes for the novel reader */ @@ -16,6 +18,6 @@ enum class ReaderTheme( DARK(Color(0xFF121212), Color(0xFFE0E0E0), "Dark"), LIGHT(Color(0xFFFFFFFF), Color(0xFF121212), "Light"), SEPIA(Color(0xFFF4ECD8), Color(0xFF5B4636), "Sepia"), - PAPER(Color(0xFFFDF6E3), Color(0xFF657B83), "Paper"), + PAPER(Color(0xFFFDF6E3), Color(SOLARIZED_PAPER_TEXT), "Paper"), OLED(Color(0xFF000000), Color(0xFFFFFFFF), "OLED") } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt index 1b2ace67..19b1c9d0 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt @@ -167,7 +167,7 @@ fun ChapterListSheet( leadingIcon = { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAddCheck, - contentDescription = null, + contentDescription = stringResource(R.string.chapter_filter_unread), modifier = Modifier.size(18.dp) ) } @@ -184,7 +184,7 @@ fun ChapterListSheet( leadingIcon = { Icon( imageVector = Icons.Default.LibraryAddCheck, - contentDescription = null, + contentDescription = stringResource(R.string.chapter_filter_in_library), modifier = Modifier.size(18.dp) ) } @@ -226,7 +226,11 @@ fun ChapterListSheet( ) { Icon( imageVector = if (isDeleteMode) Icons.Default.Delete else Icons.Default.Download, - contentDescription = null, + contentDescription = if (isDeleteMode) { + stringResource(R.string.common_delete) + } else { + stringResource(R.string.download_button) + }, modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(EasyReaderSpacing.xxs)) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt index 69403a33..c182a8b0 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt @@ -186,7 +186,7 @@ fun LibraryItemCard( Box { IconButton( onClick = { menuExpanded = true }, - modifier = Modifier.size(36.dp) + modifier = Modifier.minimumInteractiveComponentSize() ) { Icon( imageVector = Icons.Filled.MoreVert, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreGridSection.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreGridSection.kt index 0e14fa21..cc8aefdd 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreGridSection.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreGridSection.kt @@ -105,11 +105,19 @@ internal fun ExploreGrid( uiState.items.isEmpty() -> { item(span = { GridItemSpan(maxLineSpan) }) { - EmptyExploreState( - query = uiState.searchQuery, - hasActiveFilters = hasActiveFilters, - onClearFilters = onClearFilters - ) + if (uiState.hasError || uiState.searchFailures.isNotEmpty()) { + ErrorTile( + message = "Offline or failed to fetch results. Check your connection.", + onRetry = { onRetryFailedSource("") }, + modifier = Modifier.padding(top = EasyReaderSpacing.xxl) + ) + } else { + EmptyExploreState( + query = uiState.searchQuery, + hasActiveFilters = hasActiveFilters, + onClearFilters = onClearFilters + ) + } } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt index 94e51a33..d8ef6fa8 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt @@ -119,10 +119,14 @@ fun LibraryDrawerContent( items(recentItems, key = { "recent_${it.novelKey}" }) { novel -> QuickLibraryItem( item = novel.resumeItem, - supportingText = novel.resumeItem.currentChapter.ifBlank { "Resume where you left off" } - .let { chapter -> - if (chapter.startsWith("Resume")) chapter else "Resume $chapter" - }, + supportingText = if (novel.resumeItem.progress == 0 && + novel.resumeItem.currentChapterUrl.isBlank() + ) { + "Start reading" + } else { + val chapter = novel.resumeItem.currentChapter.ifBlank { "Resume where you left off" } + if (chapter.startsWith("Resume")) chapter else "Resume $chapter" + }, onClick = { onOpenLibraryItem(novel.resumeItem) onCloseDrawer() diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt index aec835f8..172b909f 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryNovelHelpers.kt @@ -116,3 +116,12 @@ private fun buildDrawerNovelEntry(items: List): DrawerNovelEntry { updateTimestamp = items.filter { it.hasActionableUpdate() }.maxOfOrNull { it.dateAdded } ?: Long.MIN_VALUE ) } + +internal fun getLibraryItemResumeLabel(item: LibraryItem): String { + return if (item.progress == 0 && item.currentChapterUrl.isBlank()) { + "Start reading" + } else { + "Resume ${item.currentChapter.ifBlank { "Chapter 1" }}" + } +} + diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreen.kt index cb8493de..41db3850 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreen.kt @@ -100,6 +100,17 @@ fun LibraryScreen( } } + LaunchedEffect(libraryUiState.snackbarMessage, libraryUiState.error) { + libraryUiState.snackbarMessage?.let { msg -> + snackbarHostState.showSnackbar(message = msg, duration = SnackbarDuration.Short) + libraryViewModel.consumeSnackbarMessage() + } + libraryUiState.error?.let { err -> + snackbarHostState.showSnackbar(message = err, duration = SnackbarDuration.Short) + libraryViewModel.consumeError() + } + } + Scaffold( topBar = { TopAppBar( @@ -213,15 +224,15 @@ fun LibraryScreen( if (libraryUiState.items.isEmpty()) { EmptyLibraryState( - isSearchEmpty = searchQuery.isNotBlank() && libraryUiState.isEmpty.not(), isFilteredEmpty = searchQuery.isNotBlank(), - onClearSearch = { libraryViewModel.updateSearchQuery("") } + onClearSearch = { libraryViewModel.updateSearchQuery("") }, + onBrowseSources = { navController.navigate(ExploreRoute) }, + onImportFile = onOpenFilePicker ) } else if (libraryUiState.filteredItems.isEmpty() && (searchQuery.isNotBlank() || statusFilter != SeriesReadingStatus.ALL) ) { EmptyLibraryState( - isSearchEmpty = true, isFilteredEmpty = true, onClearSearch = { libraryViewModel.updateSearchQuery("") @@ -522,9 +533,10 @@ private fun SelectionActions( @Composable private fun EmptyLibraryState( - isSearchEmpty: Boolean = false, isFilteredEmpty: Boolean = false, onClearSearch: () -> Unit = {}, + onBrowseSources: () -> Unit = {}, + onImportFile: () -> Unit = {}, query: String = "" ) { Box( @@ -568,6 +580,18 @@ private fun EmptyLibraryState( FilledTonalButton(onClick = onClearSearch) { Text("Clear search") } + } else { + Spacer(modifier = Modifier.height(EasyReaderSpacing.xs)) + Row( + horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm) + ) { + Button(onClick = onBrowseSources) { + Text("Browse sources") + } + OutlinedButton(onClick = onImportFile) { + Text("Import file") + } + } } } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt index 6cff71af..5d3e8e71 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -348,7 +349,7 @@ private fun NovelGroupHeader( if (!isExpanded) { Spacer(modifier = Modifier.height(EasyReaderSpacing.xxs)) Text( - text = "Resume ${resumeItem.currentChapter.ifBlank { "Chapter 1" }}", + text = getLibraryItemResumeLabel(resumeItem), style = MaterialTheme.typography.bodySmall, color = if (hasGroupSelection && !isGroupSelected) { MaterialTheme.colorScheme.primary @@ -437,7 +438,7 @@ private fun NovelChapterList( Spacer(modifier = Modifier.height(EasyReaderSpacing.xs)) val lastRead = items.find { it.isCurrentlyReading } ?: items.maxByOrNull { it.lastRead } - if (!uiState.isSelectionMode && lastRead != null && lastRead.progress > 0) { + if (!uiState.isSelectionMode && lastRead != null) { Button( onClick = { onChapterClick(lastRead) }, modifier = Modifier.fillMaxWidth(), @@ -447,7 +448,13 @@ private fun NovelChapterList( ), shape = RoundedCornerShape(12.dp) ) { - Text("Resume ${lastRead.currentChapter.ifBlank { "reading" }}") + Text( + if (lastRead.progress == 0 && lastRead.currentChapterUrl.isBlank()) { + "Start reading" + } else { + "Resume ${lastRead.currentChapter.ifBlank { "reading" }}" + } + ) } Spacer(modifier = Modifier.height(EasyReaderSpacing.xs)) } @@ -528,7 +535,7 @@ private fun NovelChapterList( if (chapterItem.isDownloaded) { IconButton( onClick = { libraryViewModel.removeDownload(chapterItem.id) }, - modifier = Modifier.size(32.dp) + modifier = Modifier.minimumInteractiveComponentSize() ) { Icon( imageVector = Icons.Default.DownloadDone, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt index 2122effa..59a5f34d 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt @@ -119,26 +119,31 @@ fun ReaderScreen( val view = LocalView.current val window = (view.context as? Activity)?.window val readerTheme = uiState.readerTheme + val appIsDark = isSystemInDarkTheme() + val currentAppIsDark by rememberUpdatedState(appIsDark) - LaunchedEffect(uiState.showControls, readerTheme, uiState.content) { + LaunchedEffect(uiState.showControls, readerTheme, uiState.content, appIsDark) { if (window != null) { val windowInsetsController = WindowCompat.getInsetsController(window, view) windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - val isDarkReader = readerTheme == ReaderTheme.DARK || - readerTheme == ReaderTheme.OLED + val isDark = if (uiState.content != null) { + readerTheme == ReaderTheme.DARK || readerTheme == ReaderTheme.OLED + } else { + appIsDark + } val systemBars = WindowInsetsCompat.Type.systemBars() val isReading = uiState.content != null if (isReading && !uiState.showControls) { windowInsetsController.hide(systemBars) - windowInsetsController.isAppearanceLightStatusBars = !isDarkReader - windowInsetsController.isAppearanceLightNavigationBars = !isDarkReader + windowInsetsController.isAppearanceLightStatusBars = !isDark + windowInsetsController.isAppearanceLightNavigationBars = !isDark } else { windowInsetsController.show(systemBars) - windowInsetsController.isAppearanceLightStatusBars = !isDarkReader - windowInsetsController.isAppearanceLightNavigationBars = !isDarkReader + windowInsetsController.isAppearanceLightStatusBars = !isDark + windowInsetsController.isAppearanceLightNavigationBars = !isDark } } } @@ -146,7 +151,10 @@ fun ReaderScreen( DisposableEffect(Unit) { onDispose { window?.let { - WindowCompat.getInsetsController(it, view).show(WindowInsetsCompat.Type.systemBars()) + val windowInsetsController = WindowCompat.getInsetsController(it, view) + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.isAppearanceLightStatusBars = !currentAppIsDark + windowInsetsController.isAppearanceLightNavigationBars = !currentAppIsDark } } } @@ -223,7 +231,7 @@ fun ReaderScreen( ) { @Suppress("UnusedMaterial3ScaffoldPaddingParameter") Scaffold( - containerColor = Color.Black, + containerColor = if (uiState.content != null) Color.Black else MaterialTheme.colorScheme.background, contentWindowInsets = WindowInsets(0, 0, 0, 0) ) { // Reader content is always edge-to-edge -- it must not depend on Scaffold's diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt index 4d227e45..e90d6b4c 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt @@ -47,6 +47,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.foundation.selection.toggleable +import androidx.compose.ui.semantics.Role +import io.aatricks.easyreader.ui.screens.countDistinctNovelTitles import androidx.hilt.navigation.compose.hiltViewModel import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.ui.viewmodel.BackupViewModel @@ -200,9 +203,15 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(EasyReaderSpacing.xs)) Text("Clear all downloads") } + val titlesCount = countDistinctNovelTitles(libraryState.items) + val entriesCount = libraryState.items.size SettingsRow( title = "Library size", - subtitle = "${libraryState.items.size} items" + subtitle = if (titlesCount == 1) { + "1 title · $entriesCount entries" + } else { + "$titlesCount titles · $entriesCount entries" + } ) OutlinedButton( onClick = { showClearLibraryDialog = true }, @@ -218,7 +227,19 @@ fun SettingsScreen( if (summaryUiState.supportsAi) { SettingsSection(title = "AI features") { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = summaryUiState.isEnabled, + role = Role.Switch, + onValueChange = { wantEnabled -> + if (wantEnabled && !summaryUiState.isEnabled) { + showEnableAiDialog = true + } else if (!wantEnabled) { + summaryViewModel.setAiSummaryEnabled(false) + } + } + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -241,13 +262,7 @@ fun SettingsScreen( Spacer(modifier = Modifier.size(EasyReaderSpacing.sm)) Switch( checked = summaryUiState.isEnabled, - onCheckedChange = { wantEnabled -> - if (wantEnabled && !summaryUiState.isEnabled) { - showEnableAiDialog = true - } else if (!wantEnabled) { - summaryViewModel.setAiSummaryEnabled(false) - } - } + onCheckedChange = null ) } if (summaryUiState.isInitializing) { diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt index 1a6b12af..d116174d 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt @@ -32,7 +32,8 @@ class ExploreViewModel @Inject constructor( val sources: List = emptyList(), val canLoadMore: Boolean = true, val browseMode: BrowseMode = BrowseMode.POPULAR, - val searchFailures: List = emptyList() + val searchFailures: List = emptyList(), + val hasError: Boolean = false ) private val _searchQueryFlow = MutableStateFlow("") @@ -64,7 +65,8 @@ class ExploreViewModel @Inject constructor( updateState { it.copy( isLoading = true, - canLoadMore = true + canLoadMore = true, + hasError = false ) } runCatching { @@ -80,12 +82,13 @@ class ExploreViewModel @Inject constructor( items = novels, isLoading = false, page = 1, - canLoadMore = novels.isNotEmpty() + canLoadMore = novels.isNotEmpty(), + hasError = false ) } }.onFailure { if (it is kotlinx.coroutines.CancellationException) throw it - updateState { it.copy(isLoading = false, canLoadMore = false) } + updateState { it.copy(isLoading = false, canLoadMore = false, hasError = true) } } } } @@ -120,7 +123,8 @@ class ExploreViewModel @Inject constructor( selectedItem = null, selectedItemDetails = null, isFetchingDetails = false, - canLoadMore = true + canLoadMore = true, + hasError = false ) } val tags = exploreRepository.getTags(sourceName) @@ -141,12 +145,13 @@ class ExploreViewModel @Inject constructor( availableTags = tags, isSearching = searchQuery.isNotBlank(), canLoadMore = novels.isNotEmpty(), - searchFailures = failures + searchFailures = failures, + hasError = false ) } }.onFailure { if (it is kotlinx.coroutines.CancellationException) throw it - updateState { it.copy(isLoading = false, canLoadMore = false) } + updateState { it.copy(isLoading = false, canLoadMore = false, hasError = true) } } } } @@ -160,12 +165,32 @@ class ExploreViewModel @Inject constructor( } else { _uiState.value.selectedTags + tag } - updateState { it.copy(selectedTags = newTags, isLoading = true, page = 1, canLoadMore = true) } - val novels = exploreRepository.getNovels(_uiState.value.browseMode, 1, _uiState.value.selectedSource, newTags.toList()) - updateState { it.copy(items = novels, isLoading = false, canLoadMore = novels.isNotEmpty()) } + updateState { + it.copy( + selectedTags = newTags, + isLoading = true, + page = 1, + canLoadMore = true, + hasError = false + ) + } + val novels = exploreRepository.getNovels( + _uiState.value.browseMode, + 1, + _uiState.value.selectedSource, + newTags.toList() + ) + updateState { + it.copy( + items = novels, + isLoading = false, + canLoadMore = novels.isNotEmpty(), + hasError = false + ) + } }.onFailure { if (it is kotlinx.coroutines.CancellationException) throw it - updateState { it.copy(isLoading = false, canLoadMore = false) } + updateState { it.copy(isLoading = false, canLoadMore = false, hasError = true) } } } } @@ -174,12 +199,32 @@ class ExploreViewModel @Inject constructor( currentJob?.cancel() currentJob = viewModelScope.launch { runCatching { - updateState { it.copy(selectedTags = emptySet(), isLoading = true, page = 1, canLoadMore = true) } - val novels = exploreRepository.getNovels(_uiState.value.browseMode, 1, _uiState.value.selectedSource, emptyList()) - updateState { it.copy(items = novels, isLoading = false, canLoadMore = novels.isNotEmpty()) } + updateState { + it.copy( + selectedTags = emptySet(), + isLoading = true, + page = 1, + canLoadMore = true, + hasError = false + ) + } + val novels = exploreRepository.getNovels( + _uiState.value.browseMode, + 1, + _uiState.value.selectedSource, + emptyList() + ) + updateState { + it.copy( + items = novels, + isLoading = false, + canLoadMore = novels.isNotEmpty(), + hasError = false + ) + } }.onFailure { if (it is kotlinx.coroutines.CancellationException) throw it - updateState { it.copy(isLoading = false, canLoadMore = false) } + updateState { it.copy(isLoading = false, canLoadMore = false, hasError = true) } } } } @@ -202,7 +247,8 @@ class ExploreViewModel @Inject constructor( isLoading = true, page = 1, canLoadMore = true, - searchFailures = emptyList() + searchFailures = emptyList(), + hasError = false ) } val outcome = exploreRepository.searchNovelsDetailed( @@ -215,17 +261,22 @@ class ExploreViewModel @Inject constructor( items = outcome.items, isLoading = false, canLoadMore = outcome.items.isNotEmpty(), - searchFailures = outcome.failures + searchFailures = outcome.failures, + hasError = outcome.items.isEmpty() && outcome.failures.isNotEmpty() ) } }.onFailure { if (it is kotlinx.coroutines.CancellationException) throw it - updateState { it.copy(isLoading = false, canLoadMore = false) } + updateState { it.copy(isLoading = false, canLoadMore = false, hasError = true) } } } } fun retryFailedSearchSource(sourceName: String): Unit { + if (sourceName.isBlank()) { + retry() + return + } if (_uiState.value.searchQuery.isBlank()) return viewModelScope.launch { runCatching { @@ -320,4 +371,12 @@ class ExploreViewModel @Inject constructor( loadInitialData() } + fun retry() { + if (_uiState.value.searchQuery.isNotBlank()) { + performSearch() + } else { + loadInitialData() + } + } + } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt index 76b788c9..5c92a38a 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt @@ -122,7 +122,8 @@ class LibraryViewModel @Inject constructor( currentlyReading = items.find { it.isCurrentlyReading }, chapterCacheStates = cacheStates, isLoading = manualUiState.isLoading, - error = manualUiState.error + error = manualUiState.error, + snackbarMessage = manualUiState.snackbarMessage ) } .flowOn(defaultDispatcher) @@ -165,6 +166,7 @@ class LibraryViewModel @Inject constructor( val collapsedSources: Set = emptySet(), val isLoading: Boolean = false, val error: String? = null, + val snackbarMessage: String? = null, val isSelectionMode: Boolean = false, val selectedIds: Set = emptySet(), val selectedCount: Int = 0, @@ -341,52 +343,81 @@ class LibraryViewModel @Inject constructor( fun fetchAndAdd(url: String): Unit { viewModelScope.launch { runCatching { - updateState { it.copy(isLoading = true, error = null) } + updateState { + it.copy( + isLoading = true, + error = null, + snackbarMessage = null + ) + } val trimmed = url.trim() if (repository.getItemByUrl(trimmed) != null) { throw Exception("This item already exists in your library") } val contentType = contentRepository.inferContentType(trimmed) - when { + val addedTitle = when { contentType == ContentType.EPUB -> { - val fetchedTitle = runCatching { contentRepository.fetchTitle(trimmed) }.getOrNull() ?: trimmed + val fetched = runCatching { contentRepository.fetchTitle(trimmed) } + val fetchedTitle = fetched.getOrNull() ?: trimmed + val finalTitle = fetchedTitle.trim().ifBlank { trimmed } repository.addItem( - title = fetchedTitle.trim().ifBlank { trimmed }, + title = finalTitle, url = trimmed, contentType = ContentType.EPUB, currentChapter = "Chapter 1", - baseTitle = fetchedTitle.trim().ifBlank { trimmed }, + baseTitle = finalTitle, baseNovelUrl = trimmed, sourceName = "EPUB" ) + finalTitle + } + contentType == ContentType.WEB && trimmed.startsWith("http") -> { + val resolvedTitle = addResolvedSeries(trimmed) + resolvedTitle ?: addUnresolvedItem(trimmed, contentType) } - // A web series URL: resolve it to a source (Novelight/NovelFire/… by host, or - // SmartSource) and add it as a proper, paginating series with its chapter list. - contentType == ContentType.WEB && trimmed.startsWith("http") && - addResolvedSeries(trimmed) -> Unit - // Fallback: not resolvable as a series (arbitrary page or local file) — keep the - // legacy single-item behaviour so pasting a lone chapter URL still works. else -> addUnresolvedItem(trimmed, contentType) } - updateState { it.copy(isLoading = false) } + updateState { + it.copy( + isLoading = false, + snackbarMessage = "Added \"$addedTitle\" to library", + error = null + ) + } }.onFailure { e -> - updateState { it.copy(isLoading = false, error = "Failed to add item: ${e.message}") } + updateState { + it.copy( + isLoading = false, + error = "Failed to add item: ${e.message}" + ) + } } } } - /** Returns true if [url] resolved to a source series (with chapters) and was added. */ - private suspend fun addResolvedSeries(url: String): Boolean { + /** + * Returns the series title if [url] resolved to a source series + * (with chapters) and was added, null otherwise. + */ + private suspend fun addResolvedSeries(url: String): String? { val item = runCatching { exploreRepository.getNovelDetailsByUrl(url) }.getOrNull() - if (item == null || item.chapters.isEmpty()) return false + if (item == null || item.chapters.isEmpty()) return null addExploreItemInternal(item) - return true + return item.title + } + + fun consumeSnackbarMessage() { + updateState { it.copy(snackbarMessage = null) } + } + + fun consumeError() { + updateState { it.copy(error = null) } } private var openNewChapterJob: Job? = null - private suspend fun addUnresolvedItem(url: String, contentType: ContentType) { + private suspend fun addUnresolvedItem(url: String, contentType: ContentType): String { val fetchedTitle = runCatching { contentRepository.fetchTitle(url) }.getOrNull() ?: url val fullTitle = fetchedTitle.trim().ifBlank { url } val baseTitle = TextUtils.extractBaseTitle(fullTitle, contentType) @@ -399,6 +430,7 @@ class LibraryViewModel @Inject constructor( baseNovelUrl = url, sourceName = if (url.startsWith("http")) "Web" else "File" ) + return baseTitle.ifBlank { fullTitle } } fun openNewChapter( diff --git a/app/src/test/java/io/aatricks/easyreader/ui/screens/LibraryNovelHelpersTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/screens/LibraryNovelHelpersTest.kt index 16f93507..d15f3032 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/screens/LibraryNovelHelpersTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/screens/LibraryNovelHelpersTest.kt @@ -429,4 +429,15 @@ class LibraryNovelHelpersTest { sourceName = sourceName ) } + + @Test + fun `getLibraryItemResumeLabel returns correct labels for started and unstarted items`() { + val neverRead = libraryItem(id = "1", title = "Chapter 0", baseTitle = "Novel", progress = 0, currentChapter = "Chapter 0") + val neverReadStartedUrl = neverRead.copy(currentChapterUrl = "https://example.com/ch1") + val startedRead = libraryItem(id = "2", title = "Chapter 2", baseTitle = "Novel", progress = 10, currentChapter = "Chapter 2") + + assertEquals("Start reading", getLibraryItemResumeLabel(neverRead)) + assertEquals("Resume Chapter 0", getLibraryItemResumeLabel(neverReadStartedUrl)) + assertEquals("Resume Chapter 2", getLibraryItemResumeLabel(startedRead)) + } } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModelTest.kt index d5bb25f1..a340d48e 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModelTest.kt @@ -20,6 +20,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.isNull @@ -120,4 +121,20 @@ class ExploreViewModelTest { assertNull(state.selectedItemDetails) assertFalse(state.isSearching) } + + @Test + fun `IOException during loadInitialData results in hasError state`() = runTest { + val failingRepository: ExploreRepository = mock() + whenever(failingRepository.getSourceNames()).thenReturn(listOf("NovelFire", "MangaBat")) + whenever(failingRepository.getTags(any())).thenAnswer { emptyList() } + whenever(failingRepository.getNovels(any(), any(), anyOrNull(), any())).thenAnswer { + throw java.io.IOException("No internet connection") + } + + val errorViewModel = ExploreViewModel(failingRepository) + advanceUntilIdle() + + assertTrue(errorViewModel.uiState.value.hasError) + assertFalse(errorViewModel.uiState.value.isLoading) + } } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt index 69fbe70b..39597408 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt @@ -746,6 +746,54 @@ class LibraryViewModelTest { assertEquals(inspected1, activeViewModel.uiState.value.chapterCacheStates[item1.url]) assertEquals(inspected2, activeViewModel.uiState.value.chapterCacheStates[item2.url]) } + + @Test + fun `fetchAndAdd success sets snackbarMessage and clears error`() = runTest { + val url = "https://example.com/novel/chapter-1" + whenever(libraryRepository.getItemByUrl(url)).thenReturn(null) + whenever(contentRepository.inferContentType(url)).thenReturn(ContentType.EPUB) + whenever(contentRepository.fetchTitle(url)).thenReturn("Novel Title") + + viewModel.fetchAndAdd(url) + advanceUntilIdle() + + assertEquals("Added \"Novel Title\" to library", viewModel.uiState.value.snackbarMessage) + assertNull(viewModel.uiState.value.error) + + viewModel.consumeSnackbarMessage() + advanceUntilIdle() + assertNull(viewModel.uiState.value.snackbarMessage) + } + + @Test + fun `fetchAndAdd failure sets error`() = runTest { + val url = "https://example.com/novel/chapter-1" + whenever(libraryRepository.getItemByUrl(url)).thenReturn(null) + whenever(contentRepository.inferContentType(url)).thenReturn(ContentType.EPUB) + whenever(contentRepository.fetchTitle(url)).thenReturn("Novel Title") + whenever( + libraryRepository.addItem( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + ).thenThrow(RuntimeException("Database write failed")) + + viewModel.fetchAndAdd(url) + advanceUntilIdle() + + assertEquals("Failed to add item: Database write failed", viewModel.uiState.value.error) + assertNull(viewModel.uiState.value.snackbarMessage) + + viewModel.consumeError() + advanceUntilIdle() + assertNull(viewModel.uiState.value.error) + } } private data class RecordedEnqueue(val url: String, val replaceExisting: Boolean) From df63315a64d88c386b88d9f0e07a2df7e0d313a2 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Tue, 7 Jul 2026 23:10:05 -0400 Subject: [PATCH 2/8] Improve reader interaction: tap-to-page, back behavior, haptics - Paged mode: left/right 25% tap zones turn pages (RTL-aware), center toggles controls; falls through to chapter navigation at boundaries - Remove per-element tap clickables in paged renderers so zone taps win - Back no longer loops drawer/controls forever; exits the reader when nothing is open, and predictive back is enabled in the manifest - Haptic feedback: pull-to-navigate threshold, long-press selection, chapter prev/next, tap page turns - Controls polish: gradient scrims behind floating bars, 2-line title, 'Chapter progress' slider labeling --- app/src/main/AndroidManifest.xml | 1 + .../ui/components/LibraryItemCard.kt | 8 +- .../ui/components/ReaderControls.kt | 14 ++- .../library/LibraryScreenEpubSections.kt | 8 +- .../library/LibraryScreenListSections.kt | 10 +- .../ui/screens/reader/ReaderContentArea.kt | 106 +++++++++++++++--- .../screens/reader/ReaderContentGestures.kt | 59 +++++----- .../screens/reader/ReaderContentRenderers.kt | 32 +----- .../ui/screens/reader/ReaderScreen.kt | 4 - .../ui/screens/reader/ReaderTapAction.kt | 29 +++++ .../ui/screens/ReaderTapActionTest.kt | 35 ++++++ 11 files changed, 225 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderTapAction.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/ui/screens/ReaderTapActionTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ce15373..b141094f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ Unit)? = null, onMarkFinished: (() -> Unit)? = null ) { + val hapticFeedback = LocalHapticFeedback.current var menuExpanded by remember { mutableStateOf(false) } val targetBackgroundColor = when { isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.28f) @@ -111,7 +114,10 @@ fun LibraryItemCard( ) .combinedClickable( onClick = onClick, - onLongClick = onLongClick + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + } ), colors = CardDefaults.cardColors( containerColor = backgroundColor diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderControls.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderControls.kt index 603a6081..266d0234 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderControls.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderControls.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.DpSize import io.aatricks.easyreader.ui.theme.EasyReaderSpacing +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback @Composable fun TopInfoBar( @@ -68,7 +70,7 @@ fun TopInfoBar( style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis ) if (chapterTitle.isNotBlank()) { @@ -156,7 +158,7 @@ fun BottomNavigationBar( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Progress", + text = "Chapter progress", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -182,7 +184,7 @@ fun BottomNavigationBar( valueRange = 0f..100f, modifier = Modifier .fillMaxWidth() - .semantics { contentDescription = "Reading progress, ${sliderValue.toInt()} percent" }, + .semantics { contentDescription = "Chapter progress, ${sliderValue.toInt()} percent" }, colors = sliderColors, interactionSource = sliderInteractionSource, thumb = { @@ -234,8 +236,12 @@ private fun ChapterNavButton( onClick: () -> Unit, leading: Boolean ) { + val hapticFeedback = LocalHapticFeedback.current FilledTonalButton( - onClick = onClick, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) + onClick() + }, enabled = enabled, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), modifier = Modifier.height(48.dp) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt index fb4c1c6a..b37e647f 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -49,6 +51,7 @@ internal fun EpubItemCard( libraryViewModel: LibraryViewModel, onCloseLibrary: () -> Unit ): Unit { + val hapticFeedback = LocalHapticFeedback.current var epubBook by remember { mutableStateOf(null) } var isExpanded by remember { mutableStateOf(false) } val isSelected = item.id in uiState.selectedIds @@ -98,7 +101,10 @@ internal fun EpubItemCard( } } }, - onLongClick = { libraryViewModel.toggleSelection(item.id) } + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + libraryViewModel.toggleSelection(item.id) + } ) ) { Text( diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt index 5d3e8e71..4ad3c0d0 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -282,12 +284,18 @@ private fun SelectableClickBox( onLongClick: (() -> Unit)? = null, content: @Composable BoxScope.() -> Unit ) { + val hapticFeedback = LocalHapticFeedback.current Box( modifier = modifier.then( if (onClick != null || onLongClick != null) { Modifier.combinedClickable( onClick = { onClick?.invoke() }, - onLongClick = onLongClick + onLongClick = { + if (onLongClick != null) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick.invoke() + } + } ) } else { Modifier diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt index fd3acc96..512c3e32 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListState @@ -52,8 +53,12 @@ import io.aatricks.easyreader.ui.viewmodel.stableContentElementKey import androidx.compose.runtime.mutableIntStateOf import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import io.aatricks.easyreader.ui.components.ReaderBottomEdgeBlur import io.aatricks.easyreader.ui.components.ReaderTopEdgeBlur import io.aatricks.easyreader.ui.components.applyReaderEdgeBlur @@ -63,6 +68,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.math.abs +import io.aatricks.easyreader.ui.screens.reader.ReaderTapAction +import io.aatricks.easyreader.ui.screens.reader.resolveReaderTapAction // Post-restore smoke check: if the landed visible percent drifts more than this many // percentage points from the saved percent, assume async image resize knocked us off and @@ -128,6 +135,7 @@ internal fun ContentArea( val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current + val hapticFeedback = LocalHapticFeedback.current val edgeBlurLayer = rememberGraphicsLayer() var edgeBlurCaptureGeneration by remember(content.url) { mutableIntStateOf(0) } @@ -302,8 +310,46 @@ internal fun ContentArea( // worst when dismissing the menu (its bars shrink the reliable target). // detectTapGestures cancels on any consumed movement, so the LazyColumn / // pager still win drags; only clean taps that no child consumed reach here. - .pointerInput(Unit) { - detectTapGestures { readerViewModel.toggleControls() } + .pointerInput( + uiState.isPagedMode, + uiState.isRtl, + uiState.canNavigateNext, + uiState.canNavigatePrevious, + pagerState.pageCount + ) { + detectTapGestures { offset -> + val xFraction = offset.x / size.width.toFloat() + val action = resolveReaderTapAction( + xFraction = xFraction, + isPaged = uiState.isPagedMode, + isRtl = uiState.isRtl + ) + when (action) { + ReaderTapAction.TOGGLE_CONTROLS -> readerViewModel.toggleControls() + ReaderTapAction.PAGE_FORWARD -> { + val nextPage = pagerState.currentPage + 1 + if (nextPage < pagerState.pageCount) { + coroutineScope.launch { + pagerState.animateScrollToPage(nextPage) + } + hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentTick) + } else if (uiState.canNavigateNext) { + readerViewModel.navigateToNextChapter() + } + } + ReaderTapAction.PAGE_BACK -> { + val prevPage = pagerState.currentPage - 1 + if (prevPage >= 0) { + coroutineScope.launch { + pagerState.animateScrollToPage(prevPage) + } + hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentTick) + } else if (uiState.canNavigatePrevious) { + readerViewModel.navigateToPreviousChapter(fromBottom = true) + } + } + } + } } .drawWithContent { if (uiState.showControls && edgeBlurCaptureGeneration != edgeBlurLastCaptured) { @@ -361,13 +407,26 @@ internal fun ContentArea( exit = slideOutVertically(targetOffsetY = { -it }), modifier = Modifier.align(Alignment.TopCenter) ) { - TopInfoBar( - novelName = uiState.novelName, - chapterTitle = uiState.chapterTitle, - onLibraryClick = onLibraryClick, - onShowChapterList = onShowChapterList, - onShowSettings = onShowSettings - ) + Box( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.35f), + Color.Transparent + ) + ) + ) + ) { + TopInfoBar( + novelName = uiState.novelName, + chapterTitle = uiState.chapterTitle, + onLibraryClick = onLibraryClick, + onShowChapterList = onShowChapterList, + onShowSettings = onShowSettings + ) + } } AnimatedVisibility( @@ -376,14 +435,27 @@ internal fun ContentArea( exit = slideOutVertically(targetOffsetY = { it }), modifier = Modifier.align(Alignment.BottomCenter) ) { - ReaderBottomNavigationBar( - readerViewModel = readerViewModel, - canNavigatePrevious = uiState.canNavigatePrevious, - canNavigateNext = uiState.canNavigateNext, - onPreviousClick = { readerViewModel.navigateToPreviousChapter(fromBottom = true) }, - onNextClick = { readerViewModel.navigateToNextChapter() }, - onProgressChange = { readerViewModel.seekToProgress(it) } - ) + Box( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.35f) + ) + ) + ) + ) { + ReaderBottomNavigationBar( + readerViewModel = readerViewModel, + canNavigatePrevious = uiState.canNavigatePrevious, + canNavigateNext = uiState.canNavigateNext, + onPreviousClick = { readerViewModel.navigateToPreviousChapter(fromBottom = true) }, + onNextClick = { readerViewModel.navigateToNextChapter() }, + onProgressChange = { readerViewModel.seekToProgress(it) } + ) + } } PullToNavigateOverlay( diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt index 6ecc42f9..81d3e67e 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import io.aatricks.easyreader.data.model.ChapterContent import io.aatricks.easyreader.data.model.ContentElement import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel @@ -154,11 +156,29 @@ internal fun rememberReaderNestedScrollConnection( val currentOnNavigatePrevious by rememberUpdatedState(onNavigatePrevious) val currentOnNavigateNext by rememberUpdatedState(onNavigateNext) + val hapticFeedback = LocalHapticFeedback.current var pullAmount by remember { mutableFloatStateOf(0f) } + var wasThresholdReached by remember { mutableStateOf(false) } var handledUserScrollStart by remember { mutableStateOf(false) } return remember(content, pagerState, listState, threshold) { object : NestedScrollConnection { + private fun updatePullAmount(newValue: Float) { + val isReached = abs(newValue) >= threshold + if (isReached != wasThresholdReached) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + wasThresholdReached = isReached + } + pullAmount = newValue + currentOnPullAmountChange(newValue) + } + + private fun resetPullAmount() { + wasThresholdReached = false + pullAmount = 0f + currentOnPullAmountChange(0f) + } + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val state = currentUiState if (source == NestedScrollSource.UserInput && @@ -174,27 +194,23 @@ internal fun rememberReaderNestedScrollConnection( if (state.isPagedMode) { if (pullAmount > 0 && available.x < 0) { val consumed = available.x.coerceAtLeast(-pullAmount) - pullAmount += consumed - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + consumed) return Offset(consumed, 0f) } if (pullAmount < 0 && available.x > 0) { val consumed = available.x.coerceAtMost(-pullAmount) - pullAmount += consumed - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + consumed) return Offset(consumed, 0f) } } else { if (pullAmount > 0 && available.y < 0) { val consumed = available.y.coerceAtLeast(-pullAmount) - pullAmount += consumed - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + consumed) return Offset(0f, consumed) } if (pullAmount < 0 && available.y > 0) { val consumed = available.y.coerceAtMost(-pullAmount) - pullAmount += consumed - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + consumed) return Offset(0f, consumed) } } @@ -216,47 +232,39 @@ internal fun rememberReaderNestedScrollConnection( if (state.isRtl) { if (available.x < 0 && isAtStart && state.canNavigatePrevious) { - pullAmount += available.x * 0.5f - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + available.x * 0.5f) return Offset(available.x, 0f) } else if (available.x > 0 && isAtEnd && state.canNavigateNext) { - pullAmount += available.x * 0.5f - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + available.x * 0.5f) return Offset(available.x, 0f) } } else { if (available.x > 0 && isAtStart && state.canNavigatePrevious) { - pullAmount += available.x * 0.5f - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + available.x * 0.5f) return Offset(available.x, 0f) } else if (available.x < 0 && isAtEnd && state.canNavigateNext) { - pullAmount += available.x * 0.5f - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + available.x * 0.5f) return Offset(available.x, 0f) } } if (pullAmount != 0f && !isAtAnyEdge) { - pullAmount = 0f - currentOnPullAmountChange(0f) + resetPullAmount() } } else { val isAtTop = !listState.canScrollBackward val isAtBottom = !listState.canScrollForward if (available.y > 0 && isAtTop && state.canNavigatePrevious) { - pullAmount += available.y * 0.5f - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + available.y * 0.5f) return Offset(0f, available.y) } else if (available.y < 0 && isAtBottom && state.canNavigateNext) { - pullAmount += available.y * 0.5f - currentOnPullAmountChange(pullAmount) + updatePullAmount(pullAmount + available.y * 0.5f) return Offset(0f, available.y) } if (pullAmount != 0f && !isAtTop && !isAtBottom) { - pullAmount = 0f - currentOnPullAmountChange(0f) + resetPullAmount() } } return Offset.Zero @@ -277,9 +285,8 @@ internal fun rememberReaderNestedScrollConnection( currentOnNavigateNext() } } - pullAmount = 0f + resetPullAmount() handledUserScrollStart = false - currentOnPullAmountChange(0f) return Velocity.Zero } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt index bac43cd0..159c3faa 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentRenderers.kt @@ -102,13 +102,7 @@ internal fun PagedReaderView( when (el) { is ContentElement.Placeholder -> { Box( - modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { readerViewModel.toggleControls() } - ), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( @@ -127,11 +121,6 @@ internal fun PagedReaderView( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { readerViewModel.toggleControls() } - ) .padding(uiState.margins.dp), verticalArrangement = Arrangement.spacedBy((uiState.fontSize * uiState.paragraphSpacing).dp) ) { @@ -168,7 +157,7 @@ internal fun PagedReaderView( onDimensionsResolved = { url, w, h -> readerViewModel.persistImageDimensions(url, w, h) }, - onTap = { readerViewModel.toggleControls() } + onTap = null ) } @@ -183,11 +172,6 @@ internal fun PagedReaderView( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { readerViewModel.toggleControls() } - ) ) { Text( text = el.content, @@ -222,7 +206,7 @@ internal fun PagedReaderView( onDimensionsResolved = { url, w, h -> readerViewModel.persistImageDimensions(url, w, h) }, - onTap = { readerViewModel.toggleControls() } + onTap = null ) } @@ -288,13 +272,7 @@ private fun PagedImageGroupView( if (!enableZoom) { Box( - modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { readerViewModel.toggleControls() } - ) + modifier = Modifier.fillMaxSize() ) { contentColumn() } @@ -308,7 +286,7 @@ private fun PagedImageGroupView( zoomStateKey = "${pageUrl}_${pageIndex}_group", onZoomChanged = onPageZoomChanged, lockTapWhileZoomed = true, - onTap = { readerViewModel.toggleControls() } + onTap = null ) { contentColumn() } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt index 59a5f34d..e952f42d 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt @@ -96,10 +96,6 @@ fun ReaderScreen( readerViewModel.hideControls() } - BackHandler(enabled = !drawerState.isOpen && !uiState.showControls && uiState.content != null) { - scope.launch { drawerState.open() } - } - LaunchedEffect(uiState.error) { if (uiState.error?.contains("403") == true || uiState.error?.contains("503") == true) { cloudflareUrl = uiState.lastAttemptedUrl ?: uiState.content?.url ?: "" diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderTapAction.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderTapAction.kt new file mode 100644 index 00000000..fa3ad76d --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderTapAction.kt @@ -0,0 +1,29 @@ +package io.aatricks.easyreader.ui.screens.reader + +private const val LEFT_ZONE_THRESHOLD = 0.25f +private const val RIGHT_ZONE_THRESHOLD = 0.75f + +enum class ReaderTapAction { + TOGGLE_CONTROLS, + PAGE_FORWARD, + PAGE_BACK +} + +fun resolveReaderTapAction( + xFraction: Float, + isPaged: Boolean, + isRtl: Boolean +): ReaderTapAction { + if (!isPaged) { + return ReaderTapAction.TOGGLE_CONTROLS + } + return when { + xFraction < LEFT_ZONE_THRESHOLD -> { + if (isRtl) ReaderTapAction.PAGE_FORWARD else ReaderTapAction.PAGE_BACK + } + xFraction > RIGHT_ZONE_THRESHOLD -> { + if (isRtl) ReaderTapAction.PAGE_BACK else ReaderTapAction.PAGE_FORWARD + } + else -> ReaderTapAction.TOGGLE_CONTROLS + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/ui/screens/ReaderTapActionTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/screens/ReaderTapActionTest.kt new file mode 100644 index 00000000..35bea998 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/ui/screens/ReaderTapActionTest.kt @@ -0,0 +1,35 @@ +package io.aatricks.easyreader.ui.screens + +import io.aatricks.easyreader.ui.screens.reader.ReaderTapAction +import io.aatricks.easyreader.ui.screens.reader.resolveReaderTapAction +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderTapActionTest { + + @Test + fun `scroll mode always returns TOGGLE_CONTROLS`() { + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.1f, isPaged = false, isRtl = false)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.5f, isPaged = false, isRtl = false)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.9f, isPaged = false, isRtl = false)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.1f, isPaged = false, isRtl = true)) + } + + @Test + fun `paged LTR zones`() { + assertEquals(ReaderTapAction.PAGE_BACK, resolveReaderTapAction(0.24f, isPaged = true, isRtl = false)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.25f, isPaged = true, isRtl = false)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.50f, isPaged = true, isRtl = false)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.75f, isPaged = true, isRtl = false)) + assertEquals(ReaderTapAction.PAGE_FORWARD, resolveReaderTapAction(0.76f, isPaged = true, isRtl = false)) + } + + @Test + fun `paged RTL zones`() { + assertEquals(ReaderTapAction.PAGE_FORWARD, resolveReaderTapAction(0.24f, isPaged = true, isRtl = true)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.25f, isPaged = true, isRtl = true)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.50f, isPaged = true, isRtl = true)) + assertEquals(ReaderTapAction.TOGGLE_CONTROLS, resolveReaderTapAction(0.75f, isPaged = true, isRtl = true)) + assertEquals(ReaderTapAction.PAGE_BACK, resolveReaderTapAction(0.76f, isPaged = true, isRtl = true)) + } +} From 76c930a56fd76e826d232b5b8cf10e5a1522a032 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Tue, 7 Jul 2026 23:25:14 -0400 Subject: [PATCH 3/8] Add cover art to library list and reader drawer - LibraryItem gains coverImageUrl; Room v9->v10 migration + schema export - Covers captured when adding from Explore (both direct and detail-fetch paths) and propagated to per-chapter items created while reading - Backup schema round-trips the cover URL (old backups import cleanly) - Coil thumbnails (2:3, referer-aware requests matching the Explore pattern) in LibraryItemCard and drawer continue-reading/recent rows --- app/detekt-baseline.xml | 3 +- .../10.json | 328 +++++++++++++++ .../easyreader/data/backup/BackupSchema.kt | 3 +- .../data/backup/LibraryBackupManager.kt | 6 +- .../easyreader/data/local/AppDatabase.kt | 11 +- .../easyreader/data/model/LibraryItem.kt | 3 +- .../data/repository/LibraryRepository.kt | 6 +- .../aatricks/easyreader/di/DatabaseModule.kt | 3 +- .../ui/components/LibraryItemCard.kt | 376 ++++++++++-------- .../screens/library/LibraryDrawerContent.kt | 98 +++-- .../ui/viewmodel/LibraryViewModel.kt | 14 +- .../ui/viewmodel/ReaderViewModel.kt | 3 +- .../data/local/AppDatabaseMigrationTest.kt | 80 +++- .../ui/viewmodel/LibraryViewModelTest.kt | 61 ++- .../ReaderViewModelNavigationTest.kt | 4 +- 15 files changed, 793 insertions(+), 206 deletions(-) create mode 100644 app/schemas/io.aatricks.easyreader.data.local.AppDatabase/10.json diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 50babb3e..bf2995df 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -75,6 +75,7 @@ FunctionNaming:LibraryDrawerContent.kt$@Composable private fun DrawerSectionLabel(text: String): Unit FunctionNaming:LibraryDrawerContent.kt$@Composable private fun EmptyQuickAccessState(): Unit FunctionNaming:LibraryDrawerContent.kt$@Composable private fun QuickLibraryItem( item: LibraryItem, supportingText: String, trailingLabel: String? = null, onClick: () -> Unit ): Unit + FunctionNaming:LibraryDrawerContent.kt$@Composable private fun QuickLibraryItemCover(item: LibraryItem): Unit FunctionNaming:LibraryItemCard.kt$@Composable fun LibraryItemCardPreview() FunctionNaming:LibraryItemCard.kt$@OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryItemCard( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onNewTagClick: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onResetProgress: (() -> Unit)? = null, onMarkFinished: (() -> Unit)? = null ) FunctionNaming:LibraryScreen.kt$@Composable private fun AddNovelSection( urlInput: String, onUrlChange: (String) -> Unit, onAddClick: () -> Unit, onOpenPdfClick: () -> Unit ): Unit @@ -188,7 +189,7 @@ LongParameterList:LibraryItemCard.kt$( item: LibraryItem, isSelected: Boolean, isCurrent: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onNewTagClick: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, onResetProgress: (() -> Unit)? = null, onMarkFinished: (() -> Unit)? = null ) LongParameterList:LibraryRepository.kt$LibraryRepository$( itemId: String, currentChapter: String = "", progress: FieldUpdate<Int> = FieldUpdate.Unchanged, currentChapterUrl: FieldUpdate<String> = FieldUpdate.Unchanged, lastScrollProgress: FieldUpdate<Float> = FieldUpdate.Unchanged, lastReadIndex: FieldUpdate<Int> = FieldUpdate.Unchanged, lastReadElementKey: FieldUpdate<String> = FieldUpdate.Unchanged, lastReadOffsetFraction: FieldUpdate<Float> = FieldUpdate.Unchanged ) LongParameterList:LibraryRepository.kt$LibraryRepository$( itemId: String, currentChapter: String, progress: Int, currentChapterUrl: String? = null, lastScrollProgress: Float? = null, lastReadIndex: Int? = null, lastReadElementKey: String? = null, lastReadOffsetFraction: Float? = null ) - LongParameterList:LibraryRepository.kt$LibraryRepository$( title: String, url: String, contentType: ContentType, currentChapter: String = "Chapter 1", baseTitle: String = title, baseNovelUrl: String = "", sourceName: String = "", totalChapters: Int = 0 ) + LongParameterList:LibraryRepository.kt$LibraryRepository$( title: String, url: String, contentType: ContentType, currentChapter: String = "Chapter 1", baseTitle: String = title, baseNovelUrl: String = "", sourceName: String = "", totalChapters: Int = 0, coverImageUrl: String = "" ) LongParameterList:LibraryScreen.kt$( query: String, totalCount: Int, visibleCount: Int, isSelectionMode: Boolean, selectedCount: Int, onSelectionClick: () -> Unit ) LongParameterList:LibraryScreenEpubSections.kt$( item: LibraryItem, uiState: LibraryViewModel.LibraryUiState, contentRepository: ContentRepository, readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onCloseLibrary: () -> Unit ) LongParameterList:LibraryScreenEpubSections.kt$( tocItem: EpubTocItem, epubPath: String, itemId: String, readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onCloseLibrary: () -> Unit, depth: Int = 0 ) diff --git a/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/10.json b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/10.json new file mode 100644 index 00000000..9dc3e140 --- /dev/null +++ b/app/schemas/io.aatricks.easyreader.data.local.AppDatabase/10.json @@ -0,0 +1,328 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "fec9afdebaf15aae07594f5f67087286", + "entities": [ + { + "tableName": "library_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `progress` INTEGER NOT NULL, `isCurrentlyReading` INTEGER NOT NULL, `currentChapter` TEXT NOT NULL, `currentChapterUrl` TEXT NOT NULL, `totalChapters` INTEGER NOT NULL, `contentType` TEXT NOT NULL, `dateAdded` INTEGER NOT NULL, `lastRead` INTEGER NOT NULL, `isDownloading` INTEGER NOT NULL, `lastScrollPosition` REAL NOT NULL, `lastReadIndex` INTEGER NOT NULL, `lastReadElementKey` TEXT NOT NULL, `lastReadOffsetFraction` REAL NOT NULL, `hasUpdates` INTEGER NOT NULL, `chapterSummaries` TEXT NOT NULL, `baseTitle` TEXT NOT NULL, `readingMode` TEXT NOT NULL, `baseNovelUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `isDownloaded` INTEGER NOT NULL, `downloadedAt` INTEGER, `coverImageUrl` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCurrentlyReading", + "columnName": "isCurrentlyReading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentChapter", + "columnName": "currentChapter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentChapterUrl", + "columnName": "currentChapterUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalChapters", + "columnName": "totalChapters", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "dateAdded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastRead", + "columnName": "lastRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDownloading", + "columnName": "isDownloading", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScrollPosition", + "columnName": "lastScrollPosition", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lastReadIndex", + "columnName": "lastReadIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadElementKey", + "columnName": "lastReadElementKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastReadOffsetFraction", + "columnName": "lastReadOffsetFraction", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "hasUpdates", + "columnName": "hasUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterSummaries", + "columnName": "chapterSummaries", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseTitle", + "columnName": "baseTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readingMode", + "columnName": "readingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseNovelUrl", + "columnName": "baseNovelUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "coverImageUrl", + "columnName": "coverImageUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_library_items_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_library_items_url` ON `${TABLE_NAME}` (`url`)" + }, + { + "name": "index_library_items_baseTitle", + "unique": false, + "columnNames": [ + "baseTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_baseTitle` ON `${TABLE_NAME}` (`baseTitle`)" + }, + { + "name": "index_library_items_isCurrentlyReading", + "unique": false, + "columnNames": [ + "isCurrentlyReading" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_isCurrentlyReading` ON `${TABLE_NAME}` (`isCurrentlyReading`)" + }, + { + "name": "index_library_items_lastRead", + "unique": false, + "columnNames": [ + "lastRead" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_library_items_lastRead` ON `${TABLE_NAME}` (`lastRead`)" + } + ] + }, + { + "tableName": "chapter_image_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chapterUrl` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `attempts` INTEGER NOT NULL, `lastAttemptMs` INTEGER NOT NULL, `httpStatusCode` INTEGER, PRIMARY KEY(`chapterUrl`, `imageUrl`))", + "fields": [ + { + "fieldPath": "chapterUrl", + "columnName": "chapterUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptMs", + "columnName": "lastAttemptMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "httpStatusCode", + "columnName": "httpStatusCode", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chapterUrl", + "imageUrl" + ] + }, + "indices": [ + { + "name": "index_chapter_image_state_chapterUrl", + "unique": false, + "columnNames": [ + "chapterUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_chapterUrl` ON `${TABLE_NAME}` (`chapterUrl`)" + }, + { + "name": "index_chapter_image_state_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_image_state_status` ON `${TABLE_NAME}` (`status`)" + } + ] + }, + { + "tableName": "image_dimension_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`imageUrl` TEXT NOT NULL, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `cachedAtMs` INTEGER NOT NULL, `parserVersion` INTEGER NOT NULL, PRIMARY KEY(`imageUrl`))", + "fields": [ + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAtMs", + "columnName": "cachedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parserVersion", + "columnName": "parserVersion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "imageUrl" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fec9afdebaf15aae07594f5f67087286')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt b/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt index 5c83c144..9bf27cb6 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/backup/BackupSchema.kt @@ -64,5 +64,6 @@ data class LibraryItemBackup( val sourceName: String, val isDownloaded: Boolean = false, val downloadedAt: Long? = null, - val bundledEpubPath: String? = null + val bundledEpubPath: String? = null, + val coverImageUrl: String = "" ) diff --git a/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt b/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt index 5e3522b8..1892cbc4 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/backup/LibraryBackupManager.kt @@ -250,7 +250,8 @@ private fun LibraryItem.toBackup(bundledPath: String?): LibraryItemBackup = Libr sourceName = sourceName, isDownloaded = isDownloaded, downloadedAt = downloadedAt, - bundledEpubPath = bundledPath + bundledEpubPath = bundledPath, + coverImageUrl = coverImageUrl ) private fun LibraryItemBackup.toEntity(rewrittenUrl: String, fileVerified: Boolean): LibraryItem = LibraryItem( @@ -278,5 +279,6 @@ private fun LibraryItemBackup.toEntity(rewrittenUrl: String, fileVerified: Boole baseNovelUrl = baseNovelUrl, sourceName = sourceName, isDownloaded = isDownloaded && fileVerified, - downloadedAt = if (isDownloaded && fileVerified) downloadedAt else null + downloadedAt = if (isDownloaded && fileVerified) downloadedAt else null, + coverImageUrl = coverImageUrl ) diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt index 9065f35e..d027942a 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/AppDatabase.kt @@ -11,7 +11,7 @@ import io.aatricks.easyreader.data.model.LibraryItem @Database( entities = [LibraryItem::class, ChapterImageStateEntity::class, ImageDimensionEntity::class], - version = 9, + version = 10, exportSchema = true ) @TypeConverters(Converters::class) @@ -21,6 +21,9 @@ abstract class AppDatabase : RoomDatabase() { abstract fun imageDimensionDao(): ImageDimensionDao companion object { + private const val DB_VERSION_9 = 9 + private const val DB_VERSION_10 = 10 + val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { // Remove the redundant 'type' column by recreating the table @@ -299,5 +302,11 @@ abstract class AppDatabase : RoomDatabase() { """.trimIndent()) } } + + val MIGRATION_9_10 = object : Migration(DB_VERSION_9, DB_VERSION_10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE library_items ADD COLUMN coverImageUrl TEXT NOT NULL DEFAULT ''") + } + } } } diff --git a/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt b/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt index 843adf46..5bea367d 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/model/LibraryItem.kt @@ -47,7 +47,8 @@ data class LibraryItem( val baseNovelUrl: String = "", val sourceName: String = "", val isDownloaded: Boolean = false, - val downloadedAt: Long? = null + val downloadedAt: Long? = null, + val coverImageUrl: String = "" ) { init { require(title.isNotBlank()) { "Title cannot be blank" } diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt index 65b35bad..a1fbae56 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt @@ -109,7 +109,8 @@ class LibraryRepository @Inject constructor( baseTitle: String = title, baseNovelUrl: String = "", sourceName: String = "", - totalChapters: Int = 0 + totalChapters: Int = 0, + coverImageUrl: String = "" ): LibraryItem = io { val newItem = LibraryItem( id = UUID.randomUUID().toString(), @@ -123,7 +124,8 @@ class LibraryRepository @Inject constructor( baseTitle = baseTitle, baseNovelUrl = baseNovelUrl, sourceName = sourceName, - totalChapters = totalChapters + totalChapters = totalChapters, + coverImageUrl = coverImageUrl ) libraryDao.insertItem(newItem) diff --git a/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt b/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt index b35d1b99..abb47147 100644 --- a/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt +++ b/app/src/main/java/io/aatricks/easyreader/di/DatabaseModule.kt @@ -32,7 +32,8 @@ object DatabaseModule { AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, - AppDatabase.MIGRATION_8_9 + AppDatabase.MIGRATION_8_9, + AppDatabase.MIGRATION_9_10 ) .build() } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt index 3b5c1bc8..fe2a3c08 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt @@ -33,6 +33,43 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.aatricks.easyreader.data.model.LibraryItem import io.aatricks.easyreader.data.model.hasActionableUpdate +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.crossfade + +@Composable +fun rememberLibraryCoverImageRequest(item: LibraryItem): ImageRequest { + val context = LocalContext.current + + return remember(item.coverImageUrl, item.url, item.sourceName) { + val refererUrl = item.baseNovelUrl.ifBlank { item.url } + val uri = try { + java.net.URI(refererUrl) + } catch (_: Exception) { + null + } + + var referer = if (uri != null) "${uri.scheme}://${uri.host}/" else refererUrl + if (item.sourceName == "MangaBat" || referer.contains("mangabat")) { + referer = "https://www.mangabats.com/" + } else if (referer.contains("manganato")) { + referer = "https://manganato.com/" + } + + ImageRequest.Builder(context) + .data(item.coverImageUrl) + .httpHeaders(NetworkHeaders.Builder().set("Referer", referer).build()) + .crossfade(true) + .build() + } +} + +private val COVER_THUMBNAIL_WIDTH = 56.dp +private const val COVER_ASPECT_RATIO = 0.6666667f /** * Library item card component displaying novel information and progress. @@ -126,188 +163,211 @@ fun LibraryItemCard( defaultElevation = if (isSelected) 4.dp else 1.dp ) ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .padding(14.dp) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Title Row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = item.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal, - color = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + if (item.coverImageUrl.isNotBlank()) { + val imageRequest = rememberLibraryCoverImageRequest(item) + AsyncImage( + model = imageRequest, + contentDescription = null, + modifier = Modifier + .width(COVER_THUMBNAIL_WIDTH) + .aspectRatio(COVER_ASPECT_RATIO) + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Crop ) - - // Current reading badge - if (isCurrent) { - Badge( - containerColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = "Reading", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } else if (item.hasActionableUpdate()) { - Badge( - containerColor = MaterialTheme.colorScheme.tertiary, - modifier = Modifier - .padding(start = 8.dp) - .then( - if (onNewTagClick != null) { - Modifier.clickable { onNewTagClick() } - } else Modifier - ) - ) { - Text( - text = "New", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onTertiary - ) - } - } - - // Selection indicator - if (isSelected) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(start = 8.dp) - .size(22.dp) + Spacer(modifier = Modifier.width(14.dp)) + } + Column( + modifier = Modifier + .weight(1f) + ) { + // Title Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal, + color = if (isCurrent) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - } else if (onDelete != null || onResetProgress != null || onMarkFinished != null) { - Box { - IconButton( - onClick = { menuExpanded = true }, - modifier = Modifier.minimumInteractiveComponentSize() + + // Current reading badge + if (isCurrent) { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp) ) { - Icon( - imageVector = Icons.Filled.MoreVert, - contentDescription = "More actions", - tint = MaterialTheme.colorScheme.onSurfaceVariant + Text( + text = "Reading", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary ) } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false } - ) { - if (onMarkFinished != null && item.progress < 100) { - DropdownMenuItem( - text = { Text("Mark as finished") }, - onClick = { - menuExpanded = false - onMarkFinished() - } + } else if (item.hasActionableUpdate()) { + Badge( + containerColor = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .padding(start = 8.dp) + .then( + if (onNewTagClick != null) { + Modifier.clickable { onNewTagClick() } + } else Modifier ) - } - if (onResetProgress != null && item.progress > 0) { - DropdownMenuItem( - text = { Text("Reset progress") }, - onClick = { - menuExpanded = false - onResetProgress() - } + ) { + Text( + text = "New", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiary + ) + } + } + + // Selection indicator + if (isSelected) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(start = 8.dp) + .size(22.dp) + ) + } else if (onDelete != null || onResetProgress != null || onMarkFinished != null) { + Box { + IconButton( + onClick = { menuExpanded = true }, + modifier = Modifier.minimumInteractiveComponentSize() + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "More actions", + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - if (onDelete != null) { - DropdownMenuItem( - text = { Text("Remove from library") }, - onClick = { - menuExpanded = false - onDelete() - } - ) + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + if (onMarkFinished != null && item.progress < 100) { + DropdownMenuItem( + text = { Text("Mark as finished") }, + onClick = { + menuExpanded = false + onMarkFinished() + } + ) + } + if (onResetProgress != null && item.progress > 0) { + DropdownMenuItem( + text = { Text("Reset progress") }, + onClick = { + menuExpanded = false + onResetProgress() + } + ) + } + if (onDelete != null) { + DropdownMenuItem( + text = { Text("Remove from library") }, + onClick = { + menuExpanded = false + onDelete() + } + ) + } } } } } - } - - Spacer(modifier = Modifier.height(10.dp)) - - // Chapter Progress - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - val chapterNumber = io.aatricks.easyreader.util.TextUtils.extractChapterNumber(item.currentChapter) - val isLastChapter = chapterNumber != null && item.totalChapters > 0 && chapterNumber.toInt() >= item.totalChapters - val chapterText = if (isLastChapter) { - val numberStr = if (chapterNumber % 1.0 == 0.0) { - chapterNumber.toInt().toString() - } else { - chapterNumber.toString() - } - "Last Chapter - $numberStr" - } else { - "Chapter ${item.currentChapter} / ${item.totalChapters}" - } - - Text( - text = chapterText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Spacer(modifier = Modifier.height(10.dp)) - Text( - text = "${item.progress}%", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - color = if (item.progress == 100) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.height(6.dp)) - - // Progress Bar - LinearProgressIndicator( - progress = { item.progress / 100f }, - modifier = Modifier - .fillMaxWidth() - .height(5.dp) - .clip(RoundedCornerShape(999.dp)) - .semantics { contentDescription = "${item.progress} percent read" }, - color = when { - item.progress == 100 -> MaterialTheme.colorScheme.primary - item.progress > 50 -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.tertiary - }, - trackColor = MaterialTheme.colorScheme.surfaceVariant - ) - - // Download status indicator (if applicable) - if (item.isDownloading) { - Spacer(modifier = Modifier.height(8.dp)) + // Chapter Progress Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.secondary + val chapterNumber = io.aatricks.easyreader.util.TextUtils.extractChapterNumber(item.currentChapter) + val isLastChapter = chapterNumber != null && item.totalChapters > 0 && chapterNumber.toInt() >= item.totalChapters + + val chapterText = if (isLastChapter) { + val numberStr = if (chapterNumber % 1.0 == 0.0) { + chapterNumber.toInt().toString() + } else { + chapterNumber.toString() + } + "Last Chapter - $numberStr" + } else { + "Chapter ${item.currentChapter} / ${item.totalChapters}" + } + + Text( + text = chapterText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( - text = "Downloading...", + text = "${item.progress}%", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary + fontWeight = FontWeight.Medium, + color = if (item.progress == 100) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) } + + Spacer(modifier = Modifier.height(6.dp)) + + // Progress Bar + LinearProgressIndicator( + progress = { item.progress / 100f }, + modifier = Modifier + .fillMaxWidth() + .height(5.dp) + .clip(RoundedCornerShape(999.dp)) + .semantics { contentDescription = "${item.progress} percent read" }, + color = when { + item.progress == 100 -> MaterialTheme.colorScheme.primary + item.progress > 50 -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.tertiary + }, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + + // Download status indicator (if applicable) + if (item.isDownloading) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.secondary + ) + Text( + text = "Downloading...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } } } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt index d8ef6fa8..d1efadf9 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt @@ -20,6 +20,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.aatricks.easyreader.data.model.LibraryItem import io.aatricks.easyreader.ui.theme.EasyReaderSpacing +import coil3.compose.AsyncImage +import androidx.compose.ui.layout.ContentScale +import io.aatricks.easyreader.ui.components.rememberLibraryCoverImageRequest + +private val DRAWER_HERO_COVER_WIDTH = 56.dp +private val DRAWER_COVER_WIDTH = 40.dp +private const val COVER_ASPECT_RATIO = 0.6666667f @Composable fun LibraryDrawerContent( @@ -154,37 +161,55 @@ private fun ContinueReadingCard( color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f), contentColor = MaterialTheme.colorScheme.onSurface ) { - Column( + Row( modifier = Modifier .fillMaxWidth() .padding(EasyReaderSpacing.lg), - verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm) + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "Continue Reading", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = item.baseTitle.ifBlank { item.title }, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = item.currentChapter.ifBlank { "Pick up where you left off" }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - LinearProgressIndicator( - progress = { item.progress / 100f }, - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant - ) + if (item.coverImageUrl.isNotBlank()) { + val imageRequest = rememberLibraryCoverImageRequest(item) + AsyncImage( + model = imageRequest, + contentDescription = null, + modifier = Modifier + .width(DRAWER_HERO_COVER_WIDTH) + .aspectRatio(COVER_ASPECT_RATIO) + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(EasyReaderSpacing.md)) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm) + ) { + Text( + text = "Continue Reading", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = item.baseTitle.ifBlank { item.title }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = item.currentChapter.ifBlank { "Pick up where you left off" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + LinearProgressIndicator( + progress = { item.progress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } } } } @@ -202,6 +227,23 @@ private fun DrawerSectionLabel(text: String): Unit { } } +@Composable +private fun QuickLibraryItemCover(item: LibraryItem): Unit { + if (item.coverImageUrl.isNotBlank()) { + val imageRequest = rememberLibraryCoverImageRequest(item) + AsyncImage( + model = imageRequest, + contentDescription = null, + modifier = Modifier + .width(DRAWER_COVER_WIDTH) + .aspectRatio(COVER_ASPECT_RATIO) + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(EasyReaderSpacing.md)) + } +} + @Composable private fun QuickLibraryItem( item: LibraryItem, @@ -223,6 +265,7 @@ private fun QuickLibraryItem( .padding(horizontal = EasyReaderSpacing.md, vertical = EasyReaderSpacing.sm), verticalAlignment = Alignment.CenterVertically ) { + QuickLibraryItemCover(item) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xxs) @@ -243,6 +286,7 @@ private fun QuickLibraryItem( ) } if (trailingLabel != null) { + Spacer(modifier = Modifier.width(EasyReaderSpacing.xs)) AssistChip( onClick = onClick, label = { Text(trailingLabel) } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt index 5c92a38a..a19d8057 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt @@ -243,8 +243,11 @@ class LibraryViewModel @Inject constructor( * "open new chapter" work. Caller owns the coroutine + loading/error state. */ private suspend fun addExploreItemInternal(item: ExploreItem) { + val details = if (item.readingUrl == null) { + exploreRepository.getNovelDetails(item.url, item.source) + } else null val readingUrl = item.readingUrl - ?: exploreRepository.getNovelDetails(item.url, item.source)?.readingUrl + ?: details?.readingUrl ?: item.url if (repository.getItemByUrl(readingUrl) != null) { @@ -252,8 +255,9 @@ class LibraryViewModel @Inject constructor( } val contentType = determineContentType(readingUrl) + val coverImageUrl = item.coverUrl ?: details?.coverUrl ?: "" if (contentType == ContentType.WEB) { - addWebExploreItem(item, readingUrl) + addWebExploreItem(item.copy(coverUrl = coverImageUrl), readingUrl) } else { repository.addItem( title = item.title, @@ -263,7 +267,8 @@ class LibraryViewModel @Inject constructor( baseTitle = item.title, baseNovelUrl = item.url, sourceName = item.source, - totalChapters = item.chapterCount + totalChapters = item.chapterCount, + coverImageUrl = coverImageUrl ) } } @@ -294,7 +299,8 @@ class LibraryViewModel @Inject constructor( baseTitle = item.title, baseNovelUrl = item.url, sourceName = item.source, - totalChapters = item.chapterCount + totalChapters = item.chapterCount, + coverImageUrl = item.coverUrl.orEmpty() ) } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt index 78bb4c80..ff284aa6 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt @@ -844,7 +844,8 @@ class ReaderViewModel @Inject constructor( currentChapter = chapterLabel, baseTitle = baseTitle, baseNovelUrl = currentItem.baseNovelUrl, - sourceName = currentItem.sourceName + sourceName = currentItem.sourceName, + coverImageUrl = currentItem.coverImageUrl ) libraryRepository.updateReadingMode(newItem.id, currentItem.readingMode) newItem.id diff --git a/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt b/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt index 47327fde..3012eabb 100644 --- a/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/data/local/AppDatabaseMigrationTest.kt @@ -326,6 +326,81 @@ class AppDatabaseMigrationTest { } } + @Test + fun migrate9To10() { + val dbName = migrationDbName("9-to-10") + createDatabaseAtVersion( + dbName = dbName, + version = 9, + createTableSql = """ + CREATE TABLE library_items ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + url TEXT NOT NULL, + timestamp INTEGER NOT NULL, + progress INTEGER NOT NULL, + isCurrentlyReading INTEGER NOT NULL, + currentChapter TEXT NOT NULL, + currentChapterUrl TEXT NOT NULL, + totalChapters INTEGER NOT NULL, + contentType TEXT NOT NULL, + dateAdded INTEGER NOT NULL, + lastRead INTEGER NOT NULL, + isDownloading INTEGER NOT NULL, + lastScrollPosition REAL NOT NULL, + lastReadIndex INTEGER NOT NULL, + lastReadElementKey TEXT NOT NULL DEFAULT '', + lastReadOffsetFraction REAL NOT NULL DEFAULT -1, + hasUpdates INTEGER NOT NULL, + chapterSummaries TEXT NOT NULL, + baseTitle TEXT NOT NULL, + readingMode TEXT NOT NULL, + baseNovelUrl TEXT NOT NULL, + sourceName TEXT NOT NULL, + isDownloaded INTEGER NOT NULL DEFAULT 0, + downloadedAt INTEGER + ) + """.trimIndent(), + indexSqls = CURRENT_INDEX_SQL + listOf( + """ + CREATE TABLE IF NOT EXISTS chapter_image_state ( + chapterUrl TEXT NOT NULL, + imageUrl TEXT NOT NULL, + status TEXT NOT NULL, + attempts INTEGER NOT NULL, + lastAttemptMs INTEGER NOT NULL, + httpStatusCode INTEGER, + PRIMARY KEY(chapterUrl, imageUrl) + ) + """.trimIndent(), + "CREATE INDEX IF NOT EXISTS index_chapter_image_state_chapterUrl ON chapter_image_state (chapterUrl)", + "CREATE INDEX IF NOT EXISTS index_chapter_image_state_status ON chapter_image_state (status)", + """ + CREATE TABLE IF NOT EXISTS image_dimension_cache ( + imageUrl TEXT NOT NULL PRIMARY KEY, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + cachedAtMs INTEGER NOT NULL, + parserVersion INTEGER NOT NULL + ) + """.trimIndent() + ), + insertSqls = emptyList() + ) + + migrationTestHelper.runMigrationsAndValidate( + dbName, + 10, + true, + AppDatabase.MIGRATION_9_10 + ).use { database -> + assertTrue(hasColumn(database, "coverImageUrl")) + database.query(SimpleSQLiteQuery("SELECT coverImageUrl FROM library_items")).use { cursor -> + assertNotNull(cursor) + } + } + } + @Test fun migrate4To5() { val dbName = migrationDbName("4-to-5") @@ -696,7 +771,7 @@ class AppDatabaseMigrationTest { """.trimIndent() companion object { - private const val CURRENT_VERSION = 9 + private const val CURRENT_VERSION = 10 private val ALL_MIGRATIONS = arrayOf( AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, @@ -705,7 +780,8 @@ class AppDatabaseMigrationTest { AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, - AppDatabase.MIGRATION_8_9 + AppDatabase.MIGRATION_8_9, + AppDatabase.MIGRATION_9_10 ) private val CURRENT_INDEX_SQL = listOf( "CREATE UNIQUE INDEX index_library_items_url ON library_items (url)", diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt index 39597408..507846e7 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt @@ -237,6 +237,7 @@ class LibraryViewModelTest { any(), any(), any(), + any(), any() ) ).thenReturn(createdItem) @@ -255,7 +256,8 @@ class LibraryViewModelTest { insertedBaseTitle.capture(), insertedBaseNovelUrl.capture(), insertedSourceName.capture(), - insertedTotalChapters.capture() + insertedTotalChapters.capture(), + any() ) assertEquals("Chapter 10", insertedTitle.firstValue) assertEquals(latestUrl, insertedUrl.firstValue) @@ -337,6 +339,7 @@ class LibraryViewModelTest { any(), any(), any(), + any(), any() ) ).thenReturn( @@ -395,7 +398,7 @@ class LibraryViewModelTest { ) advanceUntilIdle() - verify(libraryRepository, never()).addItem(any(), any(), any(), any(), any(), any(), any(), any()) + verify(libraryRepository, never()).addItem(any(), any(), any(), any(), any(), any(), any(), any(), any()) assertEquals(listOf(RecordedEnqueue(chapter.url, replaceExisting = false)), queue.enqueued) verify(contentRepository, never()).prefetchWithProgress(any(), any(), any()) verify(libraryRepository, never()).markDownloaded(existingItem.id, true) @@ -505,7 +508,7 @@ class LibraryViewModelTest { whenever(libraryRepository.getItemByUrl(chapter.url)).thenReturn(null) whenever( - libraryRepository.addItem(any(), any(), any(), any(), any(), any(), any(), any()) + libraryRepository.addItem(any(), any(), any(), any(), any(), any(), any(), any(), any()) ).thenReturn( LibraryItem( id = "chapter-14-id", @@ -780,6 +783,7 @@ class LibraryViewModelTest { any(), any(), any(), + any(), any() ) ).thenThrow(RuntimeException("Database write failed")) @@ -794,6 +798,57 @@ class LibraryViewModelTest { advanceUntilIdle() assertNull(viewModel.uiState.value.error) } + + @Test + fun `addExploreItem successfully adds item with cover image URL`() = runTest { + val item = ExploreItem( + title = "Novel Title", + url = "https://example.com/novel", + coverUrl = "https://example.com/cover.jpg", + source = "Source1", + readingUrl = "https://example.com/novel/read", + chapterCount = 42 + ) + whenever(libraryRepository.getItemByUrl(item.readingUrl!!)).thenReturn(null) + val expectedItem = LibraryItem( + id = "new-id", + title = item.title, + url = item.readingUrl!!, + baseTitle = item.title, + baseNovelUrl = item.url, + sourceName = item.source, + coverImageUrl = item.coverUrl!! + ) + whenever( + libraryRepository.addItem( + title = eq("Novel Title - Chapter 1"), + url = eq(item.readingUrl!!), + contentType = eq(ContentType.WEB), + currentChapter = eq("Chapter 1"), + baseTitle = eq(item.title), + baseNovelUrl = eq(item.url), + sourceName = eq(item.source), + totalChapters = eq(item.chapterCount), + coverImageUrl = eq(item.coverUrl!!) + ) + ).thenReturn(expectedItem) + + viewModel.addExploreItem(item) + advanceUntilIdle() + + verify(libraryRepository).addItem( + title = eq("Novel Title - Chapter 1"), + url = eq(item.readingUrl!!), + contentType = eq(ContentType.WEB), + currentChapter = eq("Chapter 1"), + baseTitle = eq(item.title), + baseNovelUrl = eq(item.url), + sourceName = eq(item.source), + totalChapters = eq(item.chapterCount), + coverImageUrl = eq(item.coverUrl!!) + ) + assertNull(viewModel.uiState.value.error) + } } private data class RecordedEnqueue(val url: String, val replaceExisting: Boolean) diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt index ad92da3f..440fbb30 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModelNavigationTest.kt @@ -200,7 +200,7 @@ class ReaderViewModelNavigationTest { whenever(libraryRepository.getItemById(currentItem.id)).thenReturn(currentItem) whenever(libraryRepository.getItemById(nextItem.id)).thenReturn(nextItem) whenever(libraryRepository.getChaptersByBaseTitle(currentItem.baseTitle)).thenReturn(listOf(currentItem, nextItem)) - whenever(libraryRepository.addItem(any(), eq(nextUrl), eq(ContentType.WEB), any(), any(), any(), any(), any())) + whenever(libraryRepository.addItem(any(), eq(nextUrl), eq(ContentType.WEB), any(), any(), any(), any(), any(), any())) .thenReturn(nextItem) whenever(exploreRepository.getNovelDetails(any(), any())).thenReturn(null) @@ -250,7 +250,7 @@ class ReaderViewModelNavigationTest { whenever(libraryRepository.getItemById(currentItem.id)).thenReturn(currentItem) whenever(libraryRepository.getItemById(targetItem.id)).thenReturn(targetItem) whenever(libraryRepository.getChaptersByBaseTitle(currentItem.baseTitle)).thenReturn(listOf(currentItem, targetItem)) - whenever(libraryRepository.addItem(any(), eq(targetUrl), eq(ContentType.WEB), any(), any(), any(), any(), any())) + whenever(libraryRepository.addItem(any(), eq(targetUrl), eq(ContentType.WEB), any(), any(), any(), any(), any(), any())) .thenReturn(targetItem) whenever(exploreRepository.getNovelDetails(any(), any())).thenReturn(null) From 7f9d876dd476dcf3cf6dc3cd0239c65bd1f2bdff Mon Sep 17 00:00:00 2001 From: Aatricks Date: Tue, 7 Jul 2026 23:53:45 -0400 Subject: [PATCH 4/8] Add Appearance settings and finish a11y/destructive-action consistency - Settings gains an Appearance section: System/Light/Dark theme override, dynamic color toggle (Android 12+, disables accent with explanation), and the accent palette picker (same preference as the reader sheet) - Theme mode and dynamic color persist via PreferencesManager snapshot flow and apply live in MainActivity - Per-chapter download removal now confirms with a snackbar in the reader - Selection/selected semantics on library cards and reader theme swatches; 48dp targets on explore filter chips --- app/detekt-baseline.xml | 14 +- .../io/aatricks/easyreader/MainActivity.kt | 14 +- .../data/local/PreferencesManager.kt | 35 ++++ .../ui/components/ChapterListSheet.kt | 6 +- .../ui/components/LibraryItemCard.kt | 6 +- .../ui/components/ReaderSettingsSheet.kt | 9 +- .../ui/screens/explore/ExploreScreen.kt | 2 + .../ui/screens/reader/ReaderScreen.kt | 9 +- .../ui/screens/settings/SettingsScreen.kt | 149 +++++++++++++++++- .../ui/screens/settings/SettingsViewModel.kt | 31 ++++ .../data/local/PreferencesManagerTest.kt | 53 +++++++ .../screens/settings/SettingsViewModelTest.kt | 50 ++++++ 12 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsViewModel.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/data/local/PreferencesManagerTest.kt create mode 100644 app/src/test/java/io/aatricks/easyreader/ui/screens/settings/SettingsViewModelTest.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index bf2995df..d1dcc544 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -10,7 +10,7 @@ ComplexCondition:SummaryService.kt$SummaryService$text.contains("\"") || text.contains("'") || lowerText.contains("said") || lowerText.contains("asked") ComplexCondition:UrlSanitizer.kt$UrlSanitizer$scheme.all { it.isLetterOrDigit() || it == '-' || it == '+' || it == '.' } ComplexCondition:UrlSecurity.kt$UrlSecurity$address.isLoopbackAddress || address.isAnyLocalAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress - CyclomaticComplexMethod:ChapterListSheet.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChapterListSheet( uiState: ReaderViewModel.ReaderUiState, libraryViewModel: LibraryViewModel, onDismiss: () -> Unit, onNavigateToChapter: (String, String) -> Unit, sheetState: SheetState ) + CyclomaticComplexMethod:ChapterListSheet.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChapterListSheet( uiState: ReaderViewModel.ReaderUiState, libraryViewModel: LibraryViewModel, onDismiss: () -> Unit, onNavigateToChapter: (String, String) -> Unit, onDownloadRemoved: () -> Unit, sheetState: SheetState ) CyclomaticComplexMethod:ChapterListSheet.kt$internal fun chapterCacheStatusKind( isCurrent: Boolean, cacheState: PrefetchResult?, isInLibrary: Boolean, isDownloaded: Boolean = false ): ChapterStatus? CyclomaticComplexMethod:EpubContentLoader.kt$EpubContentLoader$private suspend fun enrichEpubImageDimensionsFromCache( els: List<ContentElement> ): List<ContentElement> CyclomaticComplexMethod:ErrorMessages.kt$ErrorMessages$fun fromRaw(raw: String?): Friendly @@ -38,7 +38,7 @@ CyclomaticComplexMethod:ReaderViewModel.kt$ReaderViewModel$fun loadEpubChapter( epubPath: String, href: String, libraryItemId: String? = null, fromBottom: Boolean = false, isSilent: Boolean = false ) CyclomaticComplexMethod:ReaderViewModel.kt$ReaderViewModel$private suspend fun handleLoadSuccess( result: ContentResult.Success, libraryItemId: String?, fromBottom: Boolean ) CyclomaticComplexMethod:ReaderViewModel.kt$ReaderViewModel$suspend fun persistLifecycleProgress() - CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, backupViewModel: BackupViewModel = hiltViewModel() ) + CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, backupViewModel: BackupViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel() ) CyclomaticComplexMethod:TextFormattingPipeline.kt$TextFormattingPipeline$private fun replaceSpacesPlusNewline(text: String, replacementChar: Char): String CyclomaticComplexMethod:UrlSecurity.kt$UrlSecurity$fun isSafeInetAddress(address: InetAddress): Boolean CyclomaticComplexMethod:WebContentLoader.kt$WebContentLoader$private suspend fun downloadAndCacheImageInternal( imageUrl: String, pageUrl: String, priority: ImageRequestPriority ): ImageDownloadResult @@ -46,7 +46,7 @@ CyclomaticComplexMethod:WebContentLoader.kt$WebContentLoader$suspend fun prefetch( url: String, mode: PrefetchMode, onProgress: (suspend (PrefetchResult) -> Unit)? ): PrefetchResult CyclomaticComplexMethod:ZoomableBox.kt$@Composable fun ZoomableBox( modifier: Modifier = Modifier, minScale: Float = 1f, maxScale: Float = 3f, enableZoom: Boolean = false, dynamicHeight: Boolean = false, zoomStateKey: Any? = null, onZoomChanged: ((Boolean) -> Unit)? = null, lockTapWhileZoomed: Boolean = false, onTap: (() -> Unit)? = null, content: @Composable BoxScope.() -> Unit ) EmptyFunctionBlock:HtmlParser.kt$HtmlParser.<no name provided>${} - FunctionNaming:ChapterListSheet.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChapterListSheet( uiState: ReaderViewModel.ReaderUiState, libraryViewModel: LibraryViewModel, onDismiss: () -> Unit, onNavigateToChapter: (String, String) -> Unit, sheetState: SheetState ) + FunctionNaming:ChapterListSheet.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChapterListSheet( uiState: ReaderViewModel.ReaderUiState, libraryViewModel: LibraryViewModel, onDismiss: () -> Unit, onNavigateToChapter: (String, String) -> Unit, onDownloadRemoved: () -> Unit, sheetState: SheetState ) FunctionNaming:ChapterSummaryDropdown.kt$@Composable fun ChapterSummaryDropdown( summary: String?, isGenerating: Boolean, onGenerateSummary: () -> Unit, onCancel: (() -> Unit)? = null, aiSupportedInBuild: Boolean = true, aiOptedIn: Boolean = true, onEnableAi: (() -> Unit)? = null, isInitializing: Boolean = false, isReady: Boolean = aiSupportedInBuild && aiOptedIn, modifier: Modifier = Modifier ) FunctionNaming:ContentRenderer.kt$@Composable fun AsyncImageElement( url: String, altText: String?, side: ContentElement.Image.Side = ContentElement.Image.Side.FULL, width: Int = 0, height: Int = 0, pageUrl: String = "" ) FunctionNaming:ContentRenderer.kt$@Composable fun ContentRenderer( elements: List<ContentElement>, modifier: Modifier = Modifier, backgroundColor: Color = Color.Black, textColor: Color = Color.White, pageUrl: String = "" ) @@ -120,9 +120,10 @@ FunctionNaming:ReaderStates.kt$@Composable fun LoadingState() FunctionNaming:ReaderStates.kt$@Composable private fun ReaderStatePanel( icon: ImageVector, iconTint: androidx.compose.ui.graphics.Color, iconDescription: String, title: String, body: String, action: @Composable (() -> Unit)? = null ) FunctionNaming:ReaderTiledImage.kt$@Composable fun ReaderTiledImage( imageUrl: String, pageUrl: String, sliceAspect: Float, sliceCount: Int, backgroundColor: Color, onTap: (() -> Unit)? = null ) + FunctionNaming:SettingsScreen.kt$@Composable private fun AccentThemeChip( accentTheme: AccentTheme, isSelected: Boolean, enabled: Boolean, onClick: () -> Unit ) FunctionNaming:SettingsScreen.kt$@Composable private fun SettingsRow(title: String, subtitle: String) FunctionNaming:SettingsScreen.kt$@Composable private fun SettingsSection( title: String, content: @Composable () -> Unit ) - FunctionNaming:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, backupViewModel: BackupViewModel = hiltViewModel() ) + FunctionNaming:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, backupViewModel: BackupViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel() ) FunctionNaming:Theme.kt$@Composable fun NovelScraperTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = false, accentTheme: AccentTheme = AccentTheme.MOSS, content: @Composable () -> Unit ) FunctionNaming:ZoomableBox.kt$@Composable fun ZoomableBox( modifier: Modifier = Modifier, minScale: Float = 1f, maxScale: Float = 3f, enableZoom: Boolean = false, dynamicHeight: Boolean = false, zoomStateKey: Any? = null, onZoomChanged: ((Boolean) -> Unit)? = null, lockTapWhileZoomed: Boolean = false, onTap: (() -> Unit)? = null, content: @Composable BoxScope.() -> Unit ) ImplicitDefaultLocale:ReaderSettingsSheet.kt$String.format("%.1f", uiState.lineHeight) @@ -139,7 +140,7 @@ InvalidPackageDeclaration:ReaderScreen.kt$package io.aatricks.easyreader.ui.screens LargeClass:ReaderViewModel.kt$ReaderViewModel : BaseViewModel LargeClass:WebContentLoader.kt$WebContentLoader - LongMethod:ChapterListSheet.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChapterListSheet( uiState: ReaderViewModel.ReaderUiState, libraryViewModel: LibraryViewModel, onDismiss: () -> Unit, onNavigateToChapter: (String, String) -> Unit, sheetState: SheetState ) + LongMethod:ChapterListSheet.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChapterListSheet( uiState: ReaderViewModel.ReaderUiState, libraryViewModel: LibraryViewModel, onDismiss: () -> Unit, onNavigateToChapter: (String, String) -> Unit, onDownloadRemoved: () -> Unit, sheetState: SheetState ) LongMethod:ChapterSummaryDropdown.kt$@Composable fun ChapterSummaryDropdown( summary: String?, isGenerating: Boolean, onGenerateSummary: () -> Unit, onCancel: (() -> Unit)? = null, aiSupportedInBuild: Boolean = true, aiOptedIn: Boolean = true, onEnableAi: (() -> Unit)? = null, isInitializing: Boolean = false, isReady: Boolean = aiSupportedInBuild && aiOptedIn, modifier: Modifier = Modifier ) LongMethod:ContentRenderer.kt$@Composable fun ContentRenderer( elements: List<ContentElement>, modifier: Modifier = Modifier, backgroundColor: Color = Color.Black, textColor: Color = Color.White, pageUrl: String = "" ) LongMethod:ExploreDetailSheet.kt$@OptIn(ExperimentalLayoutApi::class) @Composable fun ExploreItemDetailSheet( item: ExploreItem, isLoading: Boolean = false, isInLibrary: Boolean = false, onAddToLibrary: () -> Unit, onRead: () -> Unit ): Unit @@ -176,9 +177,10 @@ LongMethod:ReaderSettingsSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderSettingsSheet( uiState: ReaderViewModel.ReaderUiState, onDismiss: () -> Unit, onUpdatePagedMode: (Boolean) -> Unit, onUpdateRtl: (Boolean) -> Unit, onUpdateFontSize: (Float) -> Unit, onUpdateLineHeight: (Float) -> Unit, onUpdateFontFamily: (String) -> Unit, onUpdateMargins: (Int) -> Unit, onUpdateParagraphSpacing: (Float) -> Unit, onUpdateReaderTheme: (ReaderTheme) -> Unit, onUpdateAccentTheme: (AccentTheme) -> Unit, sheetState: SheetState ) LongMethod:ReaderViewModel.kt$ReaderViewModel$fun loadEpubChapter( epubPath: String, href: String, libraryItemId: String? = null, fromBottom: Boolean = false, isSilent: Boolean = false ) LongMethod:ReaderViewModel.kt$ReaderViewModel$private suspend fun handleLoadSuccess( result: ContentResult.Success, libraryItemId: String?, fromBottom: Boolean ) - LongMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, backupViewModel: BackupViewModel = hiltViewModel() ) + LongMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, backupViewModel: BackupViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel() ) LongMethod:WebContentLoader.kt$WebContentLoader$private suspend fun downloadAndCacheImageInternal( imageUrl: String, pageUrl: String, priority: ImageRequestPriority ): ImageDownloadResult LongMethod:ZoomableBox.kt$@Composable fun ZoomableBox( modifier: Modifier = Modifier, minScale: Float = 1f, maxScale: Float = 3f, enableZoom: Boolean = false, dynamicHeight: Boolean = false, zoomStateKey: Any? = null, onZoomChanged: ((Boolean) -> Unit)? = null, lockTapWhileZoomed: Boolean = false, onTap: (() -> Unit)? = null, content: @Composable BoxScope.() -> Unit ) + LongParameterList:ChapterListSheet.kt$( uiState: ReaderViewModel.ReaderUiState, libraryViewModel: LibraryViewModel, onDismiss: () -> Unit, onNavigateToChapter: (String, String) -> Unit, onDownloadRemoved: () -> Unit, sheetState: SheetState ) LongParameterList:ChapterSummaryDropdown.kt$( summary: String?, isGenerating: Boolean, onGenerateSummary: () -> Unit, onCancel: (() -> Unit)? = null, aiSupportedInBuild: Boolean = true, aiOptedIn: Boolean = true, onEnableAi: (() -> Unit)? = null, isInitializing: Boolean = false, isReady: Boolean = aiSupportedInBuild && aiOptedIn, modifier: Modifier = Modifier ) LongParameterList:ContentRenderer.kt$( url: String, altText: String?, side: ContentElement.Image.Side = ContentElement.Image.Side.FULL, width: Int = 0, height: Int = 0, pageUrl: String = "" ) LongParameterList:ContentRepository.kt$ContentRepository$( private val webLoader: WebContentLoader, private val pdfLoader: PdfContentLoader, private val epubLoader: EpubContentLoader, private val localLoader: LocalContentLoader, private val contentUriTypeResolver: ContentUriTypeResolver, @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient ) diff --git a/app/src/main/java/io/aatricks/easyreader/MainActivity.kt b/app/src/main/java/io/aatricks/easyreader/MainActivity.kt index 95ffa048..e8086705 100644 --- a/app/src/main/java/io/aatricks/easyreader/MainActivity.kt +++ b/app/src/main/java/io/aatricks/easyreader/MainActivity.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject import androidx.hilt.navigation.compose.hiltViewModel +import io.aatricks.easyreader.data.local.PreferencesManager import io.aatricks.easyreader.ui.components.appUpdateHandler import io.aatricks.easyreader.ui.viewmodel.ExploreViewModel import io.aatricks.easyreader.ui.viewmodel.UpdateViewModel @@ -60,6 +61,7 @@ class MainActivity : ComponentActivity() { private val libraryViewModel: LibraryViewModel by viewModels() @Inject lateinit var contentRepository: ContentRepository + @Inject lateinit var preferencesManager: PreferencesManager private val filePickerLauncher = registerForActivityResult( ActivityResultContracts.OpenDocument() @@ -89,10 +91,18 @@ class MainActivity : ComponentActivity() { val readerUiState by readerViewModel.uiState.collectAsState() val updateViewModel: UpdateViewModel = hiltViewModel() val updateState by updateViewModel.uiState.collectAsState() + val appearanceSettings by preferencesManager.appearanceSettings.collectAsState() + + val isSystemDark = androidx.compose.foundation.isSystemInDarkTheme() + val darkTheme = when (appearanceSettings.themeMode) { + "LIGHT" -> false + "DARK" -> true + else -> isSystemDark + } NovelScraperTheme( - darkTheme = androidx.compose.foundation.isSystemInDarkTheme(), - dynamicColor = false, + darkTheme = darkTheme, + dynamicColor = appearanceSettings.dynamicColor, accentTheme = readerUiState.accentTheme ) { val navController = rememberNavController() diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt b/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt index 5df0fc98..cbcdfd5d 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/PreferencesManager.kt @@ -30,6 +30,11 @@ data class ReaderSettingsSnapshot( val accentTheme: String ) +data class AppearanceSettingsSnapshot( + val themeMode: String, + val dynamicColor: Boolean +) + /** * SharedPreferences wrapper for type-safe preferences access */ @@ -50,11 +55,19 @@ class PreferencesManager @Inject constructor( /** Reactive view of every reader-facing preference. Emits on any mutation. */ val readerSettings: StateFlow = _readerSettings.asStateFlow() + private val _appearanceSettings = MutableStateFlow(readAppearanceSettingsSnapshot()) + + /** Reactive view of appearance settings. Emits on any mutation. */ + val appearanceSettings: StateFlow = _appearanceSettings.asStateFlow() + // Held in a field so the SharedPreferences weak-ref doesn't drop it. private val prefsListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == null || key in READER_SETTINGS_KEYS) { _readerSettings.value = readReaderSettingsSnapshot() } + if (key == null || key in APPEARANCE_SETTINGS_KEYS) { + _appearanceSettings.value = readAppearanceSettingsSnapshot() + } } init { @@ -72,6 +85,19 @@ class PreferencesManager @Inject constructor( accentTheme = prefs.getString(KEY_ACCENT_THEME, io.aatricks.easyreader.ui.theme.AccentTheme.MOSS.name) ?: io.aatricks.easyreader.ui.theme.AccentTheme.MOSS.name ) + + private fun readAppearanceSettingsSnapshot(): AppearanceSettingsSnapshot = AppearanceSettingsSnapshot( + themeMode = prefs.getString(KEY_THEME_MODE, "SYSTEM") ?: "SYSTEM", + dynamicColor = prefs.getBoolean(KEY_DYNAMIC_COLOR, false) + ) + + var themeMode: String + get() = prefs.getString(KEY_THEME_MODE, "SYSTEM") ?: "SYSTEM" + set(value) = prefs.edit().putString(KEY_THEME_MODE, value).apply() + + var dynamicColor: Boolean + get() = prefs.getBoolean(KEY_DYNAMIC_COLOR, false) + set(value) = prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, value).apply() // Last-read chapter URL, mirrored on every successful chapter load so cold launch can // restore the reader without waiting for the Room currently-reading query. @@ -221,6 +247,10 @@ class PreferencesManager @Inject constructor( private const val KEY_AI_SUMMARY_ENABLED = "ai_summary_enabled" private const val KEY_WEB_OFFLINE_PIPELINE_VERSION = "web_offline_pipeline_version" + // Appearance Settings Keys + private const val KEY_THEME_MODE = "appearance_theme_mode" + private const val KEY_DYNAMIC_COLOR = "appearance_dynamic_color" + private val READER_SETTINGS_KEYS = setOf( KEY_FONT_SIZE, KEY_LINE_HEIGHT, @@ -230,5 +260,10 @@ class PreferencesManager @Inject constructor( KEY_READER_THEME, KEY_ACCENT_THEME ) + + private val APPEARANCE_SETTINGS_KEYS = setOf( + KEY_THEME_MODE, + KEY_DYNAMIC_COLOR + ) } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt index 19b1c9d0..a0a023ce 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt @@ -73,6 +73,7 @@ fun ChapterListSheet( libraryViewModel: LibraryViewModel, onDismiss: () -> Unit, onNavigateToChapter: (String, String) -> Unit, + onDownloadRemoved: () -> Unit, sheetState: SheetState ) { var isSelectionMode by remember { mutableStateOf(false) } @@ -316,7 +317,10 @@ fun ChapterListSheet( if (!isSelectionMode && !isCurrent && !isDownloading) { if (isOfflineReady && libraryItem != null) { IconButton( - onClick = { libraryViewModel.removeDownload(libraryItem.id) } + onClick = { + libraryViewModel.removeDownload(libraryItem.id) + onDownloadRemoved() + } ) { Icon( imageVector = Icons.Default.DownloadDone, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt index fe2a3c08..2b934df8 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback @@ -155,7 +156,10 @@ fun LibraryItemCard( hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) onLongClick() } - ), + ) + .semantics { + selected = isSelected + }, colors = CardDefaults.cardColors( containerColor = backgroundColor ), diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt index 9e9c510a..03f4961f 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt @@ -37,7 +37,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import io.aatricks.easyreader.ui.util.toFontFamily @@ -82,7 +85,11 @@ private fun ReaderThemeOption( .background(theme.backgroundColor) .border(borderWidth, borderColor, CircleShape) .clickable(onClick = onClick) - .semantics { contentDescription = "Theme ${theme.name.lowercase()}" }, + .semantics { + contentDescription = "Theme ${theme.name.lowercase()}" + role = Role.Button + selected = isSelected + }, contentAlignment = Alignment.Center ) { if (isSelected) { diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt index 5e97eb8f..b31d6ebf 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt @@ -309,6 +309,7 @@ private fun ActiveFilterBar( selected = true, onClick = { onSourceSelect(null) }, label = { Text(source) }, + modifier = Modifier.minimumInteractiveComponentSize(), trailingIcon = { Icon( imageVector = Icons.Default.Close, @@ -324,6 +325,7 @@ private fun ActiveFilterBar( selected = true, onClick = { onTagToggle(tag) }, label = { Text(tag) }, + modifier = Modifier.minimumInteractiveComponentSize(), trailingIcon = { Icon( imageVector = Icons.Default.Close, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt index e952f42d..02272730 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt @@ -77,6 +77,7 @@ fun ReaderScreen( val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } var showCloudflareWebView by rememberSaveable { mutableStateOf(false) } var cloudflareUrl by rememberSaveable { mutableStateOf("") } @@ -228,7 +229,8 @@ fun ReaderScreen( @Suppress("UnusedMaterial3ScaffoldPaddingParameter") Scaffold( containerColor = if (uiState.content != null) Color.Black else MaterialTheme.colorScheme.background, - contentWindowInsets = WindowInsets(0, 0, 0, 0) + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { // Reader content is always edge-to-edge -- it must not depend on Scaffold's // (system-bar) inset padding, which Android keeps reporting as non-zero while @@ -288,6 +290,11 @@ fun ReaderScreen( readerViewModel.navigateToChapter(url, title) } }, + onDownloadRemoved = { + scope.launch { + snackbarHostState.showSnackbar("Chapter download removed") + } + }, sheetState = bottomSheetState ) } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt index e90d6b4c..ffe638f8 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsScreen.kt @@ -3,6 +3,7 @@ package io.aatricks.easyreader.ui.screens.settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.Spacer @@ -47,10 +48,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.ui.draw.clip import androidx.compose.ui.semantics.Role import io.aatricks.easyreader.ui.screens.countDistinctNovelTitles import androidx.hilt.navigation.compose.hiltViewModel +import io.aatricks.easyreader.ui.theme.AccentTheme import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.ui.viewmodel.BackupViewModel import io.aatricks.easyreader.ui.viewmodel.LibraryViewModel @@ -70,7 +78,8 @@ fun SettingsScreen( readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, - backupViewModel: BackupViewModel = hiltViewModel() + backupViewModel: BackupViewModel = hiltViewModel(), + settingsViewModel: SettingsViewModel = hiltViewModel() ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -81,6 +90,8 @@ fun SettingsScreen( val summaryUiState by summaryViewModel.uiState.collectAsState() val updateViewModel: UpdateViewModel = hiltViewModel() val updateState by updateViewModel.uiState.collectAsState() + val appearanceSettings by settingsViewModel.appearanceSettings.collectAsState() + val readerSettings by settingsViewModel.readerSettings.collectAsState() var cacheBytes by remember { mutableLongStateOf(-1L) } var downloadsBytes by remember { mutableLongStateOf(-1L) } @@ -168,6 +179,107 @@ fun SettingsScreen( .padding(horizontal = EasyReaderSpacing.md, vertical = EasyReaderSpacing.md), verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.lg) ) { + SettingsSection(title = "Appearance") { + Column(verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs)) { + Text( + text = "Theme", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs) + ) { + val themes = listOf("SYSTEM" to "System", "LIGHT" to "Light", "DARK" to "Dark") + themes.forEach { (mode, label) -> + FilterChip( + selected = appearanceSettings.themeMode == mode, + onClick = { settingsViewModel.setThemeMode(mode) }, + label = { + Text( + text = label, + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + }, + modifier = Modifier.weight(1f), + colors = settingsChipColors() + ) + } + } + } + + val showDynamicColor = android.os.Build.VERSION.SDK_INT >= ANDROID_12_SDK_INT + if (showDynamicColor) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = appearanceSettings.dynamicColor, + role = Role.Switch, + onValueChange = { settingsViewModel.setDynamicColor(it) } + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Dynamic color", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Use wallpaper colors", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.size(EasyReaderSpacing.sm)) + Switch( + checked = appearanceSettings.dynamicColor, + onCheckedChange = null + ) + } + } + + val accentEnabled = !appearanceSettings.dynamicColor + Column(verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs)) { + Text( + text = "Accent", + style = MaterialTheme.typography.bodyMedium, + color = if (accentEnabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs) + ) { + AccentTheme.entries.forEach { accentTheme -> + AccentThemeChip( + accentTheme = accentTheme, + isSelected = readerSettings.accentTheme == accentTheme.name, + enabled = accentEnabled, + onClick = { settingsViewModel.setAccentTheme(accentTheme) } + ) + } + } + if (!accentEnabled) { + Text( + text = "Dynamic color is active. Disable it to customize accent color.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + SettingsSection(title = "Storage") { SettingsRow( title = "Cache size", @@ -637,4 +749,39 @@ private fun formatBytes(bytes: Long): String { else String.format("%.1f %s", value, units[unit]) } +@Composable +private fun settingsChipColors() = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer +) + +@Composable +private fun AccentThemeChip( + accentTheme: AccentTheme, + isSelected: Boolean, + enabled: Boolean, + onClick: () -> Unit +) { + FilterChip( + selected = isSelected, + onClick = onClick, + enabled = enabled, + label = { Text(accentTheme.displayName) }, + leadingIcon = { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background( + if (enabled) accentTheme.previewColor + else accentTheme.previewColor.copy(alpha = 0.38f) + ) + ) + }, + colors = settingsChipColors() + ) +} + private const val PERCENT_MULTIPLIER = 100 +private const val ANDROID_12_SDK_INT = 31 diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsViewModel.kt new file mode 100644 index 00000000..31ef802d --- /dev/null +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/settings/SettingsViewModel.kt @@ -0,0 +1,31 @@ +package io.aatricks.easyreader.ui.screens.settings + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import io.aatricks.easyreader.data.local.AppearanceSettingsSnapshot +import io.aatricks.easyreader.data.local.PreferencesManager +import io.aatricks.easyreader.data.local.ReaderSettingsSnapshot +import io.aatricks.easyreader.ui.theme.AccentTheme +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val preferencesManager: PreferencesManager +) : ViewModel() { + + val appearanceSettings: StateFlow = preferencesManager.appearanceSettings + val readerSettings: StateFlow = preferencesManager.readerSettings + + fun setThemeMode(themeMode: String) { + preferencesManager.themeMode = themeMode + } + + fun setDynamicColor(enabled: Boolean) { + preferencesManager.dynamicColor = enabled + } + + fun setAccentTheme(accentTheme: AccentTheme) { + preferencesManager.accentTheme = accentTheme.name + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/data/local/PreferencesManagerTest.kt b/app/src/test/java/io/aatricks/easyreader/data/local/PreferencesManagerTest.kt new file mode 100644 index 00000000..d0a033d6 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/data/local/PreferencesManagerTest.kt @@ -0,0 +1,53 @@ +package io.aatricks.easyreader.data.local + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PreferencesManagerTest { + + private lateinit var preferencesManager: PreferencesManager + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + preferencesManager = PreferencesManager(context) + preferencesManager.clearAll() + } + + @Test + fun `default appearance values are correct`() = runBlocking { + assertEquals("SYSTEM", preferencesManager.themeMode) + assertFalse(preferencesManager.dynamicColor) + + val snapshot = preferencesManager.appearanceSettings.first() + assertEquals("SYSTEM", snapshot.themeMode) + assertFalse(snapshot.dynamicColor) + } + + @Test + fun `theme mode persistence and snapshot emission`() = runBlocking { + preferencesManager.themeMode = "DARK" + assertEquals("DARK", preferencesManager.themeMode) + + val snapshot = preferencesManager.appearanceSettings.first() + assertEquals("DARK", snapshot.themeMode) + } + + @Test + fun `dynamic color persistence and snapshot emission`() = runBlocking { + preferencesManager.dynamicColor = true + assertTrue(preferencesManager.dynamicColor) + + val snapshot = preferencesManager.appearanceSettings.first() + assertTrue(snapshot.dynamicColor) + } +} diff --git a/app/src/test/java/io/aatricks/easyreader/ui/screens/settings/SettingsViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/screens/settings/SettingsViewModelTest.kt new file mode 100644 index 00000000..536115c0 --- /dev/null +++ b/app/src/test/java/io/aatricks/easyreader/ui/screens/settings/SettingsViewModelTest.kt @@ -0,0 +1,50 @@ +package io.aatricks.easyreader.ui.screens.settings + +import androidx.test.core.app.ApplicationProvider +import io.aatricks.easyreader.data.local.PreferencesManager +import io.aatricks.easyreader.ui.theme.AccentTheme +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsViewModelTest { + + private lateinit var preferencesManager: PreferencesManager + private lateinit var viewModel: SettingsViewModel + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + preferencesManager = PreferencesManager(context) + preferencesManager.clearAll() + viewModel = SettingsViewModel(preferencesManager) + } + + @Test + fun `themeMode updates correctly`() = runBlocking { + viewModel.setThemeMode("DARK") + assertEquals("DARK", preferencesManager.themeMode) + assertEquals("DARK", viewModel.appearanceSettings.first().themeMode) + } + + @Test + fun `dynamicColor updates correctly`() = runBlocking { + viewModel.setDynamicColor(true) + assertTrue(preferencesManager.dynamicColor) + assertTrue(viewModel.appearanceSettings.first().dynamicColor) + } + + @Test + fun `accentTheme updates correctly`() = runBlocking { + viewModel.setAccentTheme(AccentTheme.ROSE) + assertEquals(AccentTheme.ROSE.name, preferencesManager.accentTheme) + assertEquals(AccentTheme.ROSE.name, viewModel.readerSettings.first().accentTheme) + } +} From 7389fbffda90e9c7b472f31aecb1831abf16a4b5 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 8 Jul 2026 00:01:16 -0400 Subject: [PATCH 5/8] Adopt EasyReaderSpacing tokens for matching spacing literals Mechanical sweep: padding, Arrangement.spacedBy, and Spacer dimensions that exactly matched token values now use EasyReaderSpacing. Component sizes, elevation, and corner radii untouched. --- .../ui/components/ChapterSummaryDropdown.kt | 17 +++++++++-------- .../easyreader/ui/components/ContentRenderer.kt | 11 ++++++----- .../components/ExternalUrlConfirmationDialog.kt | 5 +++-- .../ui/components/FileConfirmationDialog.kt | 5 +++-- .../easyreader/ui/components/LibraryItemCard.kt | 13 +++++++------ .../easyreader/ui/components/ReaderControls.kt | 4 ++-- .../easyreader/ui/components/ReaderImageView.kt | 5 +++-- .../ui/components/ReaderSettingsSheet.kt | 2 +- .../easyreader/ui/components/ReaderStates.kt | 7 ++++--- .../ui/screens/explore/ExploreDetailSheet.kt | 4 ++-- .../ui/screens/explore/ExploreScreen.kt | 2 +- .../library/LibraryScreenEpubSections.kt | 8 ++++---- .../library/LibraryScreenListSections.kt | 2 +- .../ui/screens/reader/ReaderContentArea.kt | 5 +++-- .../ui/screens/reader/ReaderContentGestures.kt | 5 +++-- .../ui/screens/reader/ReaderScreen.kt | 15 ++++++++------- 16 files changed, 60 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterSummaryDropdown.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterSummaryDropdown.kt index d9201f90..baca1378 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterSummaryDropdown.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterSummaryDropdown.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing @Composable fun ChapterSummaryDropdown( @@ -40,8 +41,8 @@ fun ChapterSummaryDropdown( Column( modifier = Modifier .fillMaxWidth() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(EasyReaderSpacing.sm), + verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs) ) { Column(modifier = Modifier.fillMaxWidth()) { Text( @@ -49,7 +50,7 @@ fun ChapterSummaryDropdown( style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(EasyReaderSpacing.xxs)) when { !aiSupportedInBuild -> { @@ -58,7 +59,7 @@ fun ChapterSummaryDropdown( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(EasyReaderSpacing.xxs)) Text( text = "Install the AI variant to enable on-device chapter recaps.", style = MaterialTheme.typography.bodySmall, @@ -72,7 +73,7 @@ fun ChapterSummaryDropdown( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(EasyReaderSpacing.xxs)) Text( text = "The AI model is downloaded once (a few hundred MB) and then runs offline.", style = MaterialTheme.typography.bodySmall, @@ -88,7 +89,7 @@ fun ChapterSummaryDropdown( isInitializing && !isReady -> { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm), verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator( @@ -105,10 +106,10 @@ fun ChapterSummaryDropdown( } isGenerating -> { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs)) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm), verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator( diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ContentRenderer.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ContentRenderer.kt index 739ab68c..7fa9b830 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ContentRenderer.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ContentRenderer.kt @@ -23,6 +23,7 @@ import coil3.request.crossfade import coil3.network.NetworkHeaders import coil3.network.httpHeaders import androidx.compose.ui.graphics.graphicsLayer +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.data.model.ContentElement import io.aatricks.easyreader.ui.util.imageAspectRatio import io.aatricks.easyreader.ui.util.splitImageLayer @@ -39,8 +40,8 @@ fun ContentRenderer( modifier = modifier .fillMaxSize() .background(backgroundColor) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .padding(EasyReaderSpacing.md), + verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm) ) { items(elements) { element -> when (element) { @@ -59,7 +60,7 @@ fun ContentRenderer( } } is ContentElement.PageContent -> { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm)) { element.elements.forEach { subElement -> when (subElement) { is ContentElement.Text -> { @@ -80,7 +81,7 @@ fun ContentRenderer( ) } is ContentElement.ImageGroup -> { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs)) { subElement.images.forEach { image -> AsyncImageElement( url = image.url, @@ -203,7 +204,7 @@ fun AsyncImageElement( Text( text = altText ?: "Failed to load image", color = Color.Gray, - modifier = Modifier.padding(16.dp) + modifier = Modifier.padding(EasyReaderSpacing.md) ) } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ExternalUrlConfirmationDialog.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ExternalUrlConfirmationDialog.kt index ce4af551..945e98d5 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ExternalUrlConfirmationDialog.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ExternalUrlConfirmationDialog.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.R @Composable @@ -36,13 +37,13 @@ fun ExternalUrlConfirmationDialog( text = { Column { Text(stringResource(R.string.url_confirm_intro)) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(EasyReaderSpacing.xs)) Text( text = url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(EasyReaderSpacing.md)) Text( text = stringResource(R.string.url_confirm_transient), style = MaterialTheme.typography.bodyMedium diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/FileConfirmationDialog.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/FileConfirmationDialog.kt index 7e5bc0cf..dc0cbada 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/FileConfirmationDialog.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/FileConfirmationDialog.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.R @Composable @@ -36,13 +37,13 @@ fun FileConfirmationDialog( text = { Column { Text(stringResource(R.string.file_confirm_intro)) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(EasyReaderSpacing.xs)) Text( text = fileUri, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(EasyReaderSpacing.md)) Text( text = stringResource(R.string.file_confirm_trust), style = MaterialTheme.typography.bodyMedium diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt index 2b934df8..87258542 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/LibraryItemCard.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.data.model.LibraryItem import io.aatricks.easyreader.data.model.hasActionableUpdate import androidx.compose.ui.layout.ContentScale @@ -214,7 +215,7 @@ fun LibraryItemCard( if (isCurrent) { Badge( containerColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp) + modifier = Modifier.padding(start = EasyReaderSpacing.xs) ) { Text( text = "Reading", @@ -226,7 +227,7 @@ fun LibraryItemCard( Badge( containerColor = MaterialTheme.colorScheme.tertiary, modifier = Modifier - .padding(start = 8.dp) + .padding(start = EasyReaderSpacing.xs) .then( if (onNewTagClick != null) { Modifier.clickable { onNewTagClick() } @@ -248,7 +249,7 @@ fun LibraryItemCard( contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary, modifier = Modifier - .padding(start = 8.dp) + .padding(start = EasyReaderSpacing.xs) .size(22.dp) ) } else if (onDelete != null || onResetProgress != null || onMarkFinished != null) { @@ -355,7 +356,7 @@ fun LibraryItemCard( // Download status indicator (if applicable) if (item.isDownloading) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(EasyReaderSpacing.xs)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) @@ -385,8 +386,8 @@ fun LibraryItemCardPreview() { Column( modifier = Modifier .background(Color.Black) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .padding(EasyReaderSpacing.md), + verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm) ) { LibraryItemCard( item = LibraryItem( diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderControls.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderControls.kt index 266d0234..416b435e 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderControls.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderControls.kt @@ -142,7 +142,7 @@ fun BottomNavigationBar( ) { Column( modifier = Modifier.padding(horizontal = EasyReaderSpacing.sm, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs) ) { var sliderValue by remember(progress) { mutableFloatStateOf(progress) } val sliderInteractionSource = remember { MutableInteractionSource() } @@ -170,7 +170,7 @@ fun BottomNavigationBar( ) { Text( text = "${sliderValue.toInt()}%", - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = EasyReaderSpacing.xxs), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold ) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt index f006dfd9..27763198 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderImageView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import coil3.network.NetworkHeaders @@ -365,7 +366,7 @@ fun ReaderImageView( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .padding(16.dp) + .padding(EasyReaderSpacing.md) .clickable { isError = false isLoadingHoisted = true @@ -381,7 +382,7 @@ fun ReaderImageView( text = if (imageUrl.startsWith("http")) "Tap to retry" else "Tap to reload", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(top = EasyReaderSpacing.xxs) ) } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt index 03f4961f..9145021d 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt @@ -352,7 +352,7 @@ fun ReaderSettingsSheet( ) { Text( text = stringResource(R.string.reader_preview_pangram), - modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + modifier = Modifier.padding(horizontal = 14.dp, vertical = EasyReaderSpacing.sm), style = MaterialTheme.typography.bodyMedium, fontFamily = previewFont ) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderStates.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderStates.kt index 2b6961bb..85e1d48b 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderStates.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderStates.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.util.ErrorMessages @Composable @@ -82,7 +83,7 @@ private fun ReaderStatePanel( Box( modifier = Modifier .fillMaxSize() - .padding(horizontal = 24.dp, vertical = 32.dp), + .padding(horizontal = EasyReaderSpacing.xl, vertical = EasyReaderSpacing.xxl), contentAlignment = Alignment.Center ) { Surface( @@ -92,7 +93,7 @@ private fun ReaderStatePanel( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 28.dp), + .padding(horizontal = EasyReaderSpacing.xl, vertical = 28.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp) ) { @@ -116,7 +117,7 @@ private fun ReaderStatePanel( modifier = Modifier.semantics { contentDescription = body } ) if (action != null) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(EasyReaderSpacing.xxs)) action() } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreDetailSheet.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreDetailSheet.kt index 93755652..688c0cd1 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreDetailSheet.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreDetailSheet.kt @@ -90,7 +90,7 @@ fun ExploreItemDetailSheet( if (isInLibrary) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xxs) ) { Icon( imageVector = Icons.Default.CheckCircle, @@ -117,7 +117,7 @@ fun ExploreItemDetailSheet( if (item.chapterCount > 0) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xxs) ) { Icon( imageVector = Icons.Default.AutoStories, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt index b31d6ebf..782a4d27 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt @@ -339,7 +339,7 @@ private fun ActiveFilterBar( if (hasActiveFilters) { TextButton( onClick = onClearFilters, - contentPadding = PaddingValues(horizontal = 8.dp) + contentPadding = PaddingValues(horizontal = EasyReaderSpacing.xs) ) { Text("Clear") } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt index b37e647f..c914daa4 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt @@ -166,8 +166,8 @@ private fun EpubTocItemView( var isExpanded by remember { mutableStateOf(false) } val startPadding = when (depth) { 0 -> 0.dp - 1 -> 16.dp - 2 -> 32.dp + 1 -> EasyReaderSpacing.md + 2 -> EasyReaderSpacing.xxl else -> 48.dp } @@ -197,9 +197,9 @@ private fun EpubTocItemView( modifier = Modifier.size(16.dp) ) } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(EasyReaderSpacing.xxs)) } else { - Spacer(modifier = Modifier.width(24.dp)) + Spacer(modifier = Modifier.width(EasyReaderSpacing.xl)) } Text( diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt index 4ad3c0d0..90e95078 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt @@ -520,7 +520,7 @@ private fun NovelChapterList( tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(14.dp) ) - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(EasyReaderSpacing.xxs)) } Text( text = chapterItem.currentChapter.ifBlank { "Chapter 1" }, diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt index 512c3e32..ecb3074b 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentArea.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -741,13 +742,13 @@ private fun EdgeHintChip( icon: ImageVector ) { androidx.compose.material3.Surface( - modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + modifier = Modifier.padding(vertical = EasyReaderSpacing.sm, horizontal = EasyReaderSpacing.md), shape = androidx.compose.material3.MaterialTheme.shapes.extraLarge, color = androidx.compose.material3.MaterialTheme.colorScheme.surface.copy(alpha = 0.55f), contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onSurface ) { androidx.compose.foundation.layout.Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + modifier = Modifier.padding(horizontal = EasyReaderSpacing.sm, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt index 81d3e67e..ab85dfab 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderContentGestures.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import io.aatricks.easyreader.data.model.ChapterContent @@ -98,7 +99,7 @@ internal fun PullToNavigateOverlay( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(32.dp) + modifier = Modifier.padding(EasyReaderSpacing.xxl) ) { val icon = when { isPagedMode && isPrevious -> if (isRtl) Icons.AutoMirrored.Filled.ArrowBack else Icons.AutoMirrored.Filled.ArrowForward @@ -120,7 +121,7 @@ internal fun PullToNavigateOverlay( .size(48.dp) .rotate(if (isPagedMode) 0f else rotation) ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(EasyReaderSpacing.xs)) Text( text = when { isPrevious && isThresholdReached -> "Release for Previous Chapter" diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt index 02272730..ec308ad7 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog @@ -327,7 +328,7 @@ private fun CloudflareDialog( Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(EasyReaderSpacing.md), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -354,7 +355,7 @@ private fun CloudflareDialog( if (webViewError != null) { Text( text = "Error: $webViewError", - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = EasyReaderSpacing.md), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall ) @@ -364,7 +365,7 @@ private fun CloudflareDialog( Box( modifier = Modifier .weight(1f) - .padding(horizontal = 16.dp) + .padding(horizontal = EasyReaderSpacing.md) .clip(RoundedCornerShape(8.dp)) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)) .background(Color.White) @@ -422,7 +423,7 @@ private fun CloudflareDialog( webViewError = null internalWebView?.reload() }, - modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp) + modifier = Modifier.align(Alignment.BottomEnd).padding(EasyReaderSpacing.md) ) { Icon(Icons.Default.Refresh, contentDescription = "Reload") } @@ -433,8 +434,8 @@ private fun CloudflareDialog( Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(EasyReaderSpacing.md), + horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs), verticalAlignment = Alignment.CenterVertically ) { TextButton( @@ -446,7 +447,7 @@ private fun CloudflareDialog( } ) { Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(4.dp)) + Spacer(Modifier.width(EasyReaderSpacing.xxs)) Text("Open in Browser") } From 765d4c5448b2189e469181f3452903cf5c5f425e Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 8 Jul 2026 00:11:23 -0400 Subject: [PATCH 6/8] Surface per-source failures in Explore browse mode getNovels swallowed every source exception (getOrDefault(emptyList())), so a fully-offline browse rendered the generic empty state instead of the retryable error tile. Add getNovelsDetailed mirroring searchNovelsDetailed and set hasError when a browse load returns no items but has failures. Update the ViewModel test to model the real repository contract (failures reported, not thrown). --- .../data/repository/ExploreRepository.kt | 44 +++++++++++++++---- .../ui/viewmodel/ExploreViewModel.kt | 43 +++++++++++------- .../ui/viewmodel/ExploreViewModelTest.kt | 21 +++++++-- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/ExploreRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/ExploreRepository.kt index 55783b30..18ec0fae 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/ExploreRepository.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/ExploreRepository.kt @@ -39,21 +39,49 @@ class ExploreRepository @Inject constructor( page: Int = 1, sourceName: String? = null, tags: List = emptyList() - ): List = coroutineScope { + ): List = getNovelsDetailed(mode, page, sourceName, tags).items + + /** + * Browse variant that returns per-source failure information alongside the + * merged items, mirroring [searchNovelsDetailed]. Sources fail independently; + * the multi-tag intersection path swallows failures internally and reports none. + */ + suspend fun getNovelsDetailed( + mode: BrowseMode = BrowseMode.POPULAR, + page: Int = 1, + sourceName: String? = null, + tags: List = emptyList() + ): SearchOutcome = coroutineScope { val activeSources = filterSources(sourceName) val normalizedTags = tags.map { it.trim() }.filter { it.isNotBlank() }.distinct() - val results = if (normalizedTags.size <= 1) { - activeSources.map { source -> - async { runCatching { source.getNovels(mode, page, normalizedTags) }.getOrDefault(emptyList()) } - }.awaitAll().flatten() + if (normalizedTags.size <= 1) { + val outcomes = activeSources.map { source -> + async { + runCatching { source.getNovels(mode, page, normalizedTags) }.fold( + onSuccess = { items -> source to Result.success(items) }, + onFailure = { e -> source to Result.failure(e) } + ) + } + }.awaitAll() + + val items = outcomes.flatMap { (_, result) -> result.getOrDefault(emptyList()) } + val failures = outcomes.mapNotNull { (source, result) -> + result.exceptionOrNull()?.let { e -> + SourceFailure( + sourceName = source.name, + reason = e.message?.takeIf { it.isNotBlank() } ?: e::class.simpleName, + cause = e + ) + } + } + SearchOutcome(if (sourceName == null) items.shuffled() else items, failures) } else { - activeSources.map { source -> + val results = activeSources.map { source -> async { loadNovelsWithTagIntersection(source, mode, page, normalizedTags) } }.awaitAll().flatten() + SearchOutcome(if (sourceName == null) results.shuffled() else results, emptyList()) } - - if (sourceName == null) results.shuffled() else results } suspend fun getTags(sourceName: String?): List = coroutineScope { diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt index d116174d..59d9a526 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt @@ -76,14 +76,19 @@ class ExploreViewModel @Inject constructor( availableTags = tags, ) } - val novels = exploreRepository.getNovels(_uiState.value.browseMode, 1, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) + val outcome = exploreRepository.getNovelsDetailed( + _uiState.value.browseMode, + 1, + _uiState.value.selectedSource, + _uiState.value.selectedTags.toList() + ) updateState { it.copy( - items = novels, + items = outcome.items, isLoading = false, page = 1, - canLoadMore = novels.isNotEmpty(), - hasError = false + canLoadMore = outcome.items.isNotEmpty(), + hasError = outcome.items.isEmpty() && outcome.failures.isNotEmpty() ) } }.onFailure { @@ -135,8 +140,14 @@ class ExploreViewModel @Inject constructor( novels = outcome.items failures = outcome.failures } else { - novels = exploreRepository.getNovels(_uiState.value.browseMode, 1, sourceName, emptyList()) - failures = emptyList() + val outcome = exploreRepository.getNovelsDetailed( + _uiState.value.browseMode, + 1, + sourceName, + emptyList() + ) + novels = outcome.items + failures = outcome.failures } updateState { it.copy( @@ -145,8 +156,8 @@ class ExploreViewModel @Inject constructor( availableTags = tags, isSearching = searchQuery.isNotBlank(), canLoadMore = novels.isNotEmpty(), - searchFailures = failures, - hasError = false + searchFailures = if (searchQuery.isNotBlank()) failures else emptyList(), + hasError = novels.isEmpty() && failures.isNotEmpty() ) } }.onFailure { @@ -174,7 +185,7 @@ class ExploreViewModel @Inject constructor( hasError = false ) } - val novels = exploreRepository.getNovels( + val outcome = exploreRepository.getNovelsDetailed( _uiState.value.browseMode, 1, _uiState.value.selectedSource, @@ -182,10 +193,10 @@ class ExploreViewModel @Inject constructor( ) updateState { it.copy( - items = novels, + items = outcome.items, isLoading = false, - canLoadMore = novels.isNotEmpty(), - hasError = false + canLoadMore = outcome.items.isNotEmpty(), + hasError = outcome.items.isEmpty() && outcome.failures.isNotEmpty() ) } }.onFailure { @@ -208,7 +219,7 @@ class ExploreViewModel @Inject constructor( hasError = false ) } - val novels = exploreRepository.getNovels( + val outcome = exploreRepository.getNovelsDetailed( _uiState.value.browseMode, 1, _uiState.value.selectedSource, @@ -216,10 +227,10 @@ class ExploreViewModel @Inject constructor( ) updateState { it.copy( - items = novels, + items = outcome.items, isLoading = false, - canLoadMore = novels.isNotEmpty(), - hasError = false + canLoadMore = outcome.items.isNotEmpty(), + hasError = outcome.items.isEmpty() && outcome.failures.isNotEmpty() ) } }.onFailure { diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModelTest.kt index a340d48e..7ee2e626 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModelTest.kt @@ -3,6 +3,7 @@ package io.aatricks.easyreader.ui.viewmodel import io.aatricks.easyreader.data.model.ExploreItem import io.aatricks.easyreader.data.repository.ExploreRepository import io.aatricks.easyreader.data.repository.SearchOutcome +import io.aatricks.easyreader.data.repository.SourceFailure import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -47,6 +48,8 @@ class ExploreViewModelTest { whenever(exploreRepository.searchNovels(any(), any(), any())).thenReturn(emptyList()) whenever(exploreRepository.searchNovelsDetailed(any(), any(), any())) .thenReturn(SearchOutcome(emptyList(), emptyList())) + whenever(exploreRepository.getNovelsDetailed(any(), any(), anyOrNull(), any())) + .thenReturn(SearchOutcome(emptyList(), emptyList())) whenever(exploreRepository.getNovelDetails(any(), any())).thenReturn(null) } @@ -123,13 +126,23 @@ class ExploreViewModelTest { } @Test - fun `IOException during loadInitialData results in hasError state`() = runTest { + fun `all sources failing during loadInitialData results in hasError state`() = runTest { + // The repository swallows per-source exceptions and reports them as + // SourceFailures with an empty item list — it does NOT throw. This is + // the real offline shape (verified against ExploreRepository.getNovelsDetailed). val failingRepository: ExploreRepository = mock() + val offline = java.io.IOException("No internet connection") whenever(failingRepository.getSourceNames()).thenReturn(listOf("NovelFire", "MangaBat")) whenever(failingRepository.getTags(any())).thenAnswer { emptyList() } - whenever(failingRepository.getNovels(any(), any(), anyOrNull(), any())).thenAnswer { - throw java.io.IOException("No internet connection") - } + whenever(failingRepository.getNovelsDetailed(any(), any(), anyOrNull(), any())).thenReturn( + SearchOutcome( + emptyList(), + listOf( + SourceFailure("NovelFire", offline.message, offline), + SourceFailure("MangaBat", offline.message, offline) + ) + ) + ) val errorViewModel = ExploreViewModel(failingRepository) advanceUntilIdle() From e051b8d690053189e43c580f7f628e3299480eb1 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 8 Jul 2026 00:49:23 -0400 Subject: [PATCH 7/8] Backfill covers for existing library items; move accent picker to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LibraryViewModel backfills missing covers once per process: groups cover-less web items by novel, fetches details from the item's source (or by URL for unresolved items), and writes the cover to all rows of the novel so the library list and reader drawer update reactively; per-novel failures skip silently - Remove the accent picker from the reader settings sheet (and the orphaned ReaderViewModel setter) — Settings > Appearance is now the single place to change accent --- app/detekt-baseline.xml | 8 +- .../easyreader/data/local/LibraryDao.kt | 7 + .../data/repository/LibraryRepository.kt | 5 + .../ui/components/ReaderSettingsSheet.kt | 40 ------ .../ui/screens/reader/ReaderScreen.kt | 1 - .../ui/viewmodel/LibraryViewModel.kt | 52 +++++++ .../ui/viewmodel/ReaderViewModel.kt | 5 - .../ui/viewmodel/LibraryViewModelTest.kt | 136 ++++++++++++++++++ 8 files changed, 203 insertions(+), 51 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index d1dcc544..13e0486e 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -110,11 +110,10 @@ FunctionNaming:ReaderScreen.kt$@Composable private fun ReaderContent( uiState: ReaderViewModel.ReaderUiState, readerViewModel: ReaderViewModel, onOpenLibrary: () -> Unit, onOpenLibraryScreen: () -> Unit, onShowChapterList: () -> Unit, onShowSettings: () -> Unit ): Unit FunctionNaming:ReaderScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ReaderScreen( readerViewModel: ReaderViewModel, libraryViewModelProvider: () -> LibraryViewModel, navController: NavController, onOpenFilePicker: () -> Unit, modifier: Modifier = Modifier ): Unit FunctionNaming:ReaderSettingsSheet.kt$@Composable fun SettingSlider( label: String, value: Float, onValueChange: (Float) -> Unit, valueRange: ClosedFloatingPointRange<Float>, steps: Int, displayValue: String ) - FunctionNaming:ReaderSettingsSheet.kt$@Composable private fun AccentThemeChip( accentTheme: AccentTheme, isSelected: Boolean, onClick: () -> Unit ) FunctionNaming:ReaderSettingsSheet.kt$@Composable private fun FontFamilyChip( font: String, isSelected: Boolean, onClick: () -> Unit ) FunctionNaming:ReaderSettingsSheet.kt$@Composable private fun ReaderThemeOption( theme: ReaderTheme, isSelected: Boolean, onClick: () -> Unit ) FunctionNaming:ReaderSettingsSheet.kt$@Composable private fun SettingsSectionLabel(text: String) - FunctionNaming:ReaderSettingsSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderSettingsSheet( uiState: ReaderViewModel.ReaderUiState, onDismiss: () -> Unit, onUpdatePagedMode: (Boolean) -> Unit, onUpdateRtl: (Boolean) -> Unit, onUpdateFontSize: (Float) -> Unit, onUpdateLineHeight: (Float) -> Unit, onUpdateFontFamily: (String) -> Unit, onUpdateMargins: (Int) -> Unit, onUpdateParagraphSpacing: (Float) -> Unit, onUpdateReaderTheme: (ReaderTheme) -> Unit, onUpdateAccentTheme: (AccentTheme) -> Unit, sheetState: SheetState ) + FunctionNaming:ReaderSettingsSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderSettingsSheet( uiState: ReaderViewModel.ReaderUiState, onDismiss: () -> Unit, onUpdatePagedMode: (Boolean) -> Unit, onUpdateRtl: (Boolean) -> Unit, onUpdateFontSize: (Float) -> Unit, onUpdateLineHeight: (Float) -> Unit, onUpdateFontFamily: (String) -> Unit, onUpdateMargins: (Int) -> Unit, onUpdateParagraphSpacing: (Float) -> Unit, onUpdateReaderTheme: (ReaderTheme) -> Unit, sheetState: SheetState ) FunctionNaming:ReaderStates.kt$@Composable fun EmptyState(onOpenLibrary: () -> Unit) FunctionNaming:ReaderStates.kt$@Composable fun ErrorState(error: String, onRetry: () -> Unit) FunctionNaming:ReaderStates.kt$@Composable fun LoadingState() @@ -174,7 +173,7 @@ LongMethod:ReaderProgressController.kt$ReaderProgressController$fun updateScrollPosition( scrollOffset: Float, maxScrollOffset: Float, viewportHeight: Float, index: Int, offsetFraction: Float, elementKey: String, content: ChapterContent?, canScrollForward: Boolean = true, firstVisibleItemSize: Int = 0 ) LongMethod:ReaderScreen.kt$@Composable private fun CloudflareDialog( url: String, onDismiss: () -> Unit, onRetry: () -> Unit ): Unit LongMethod:ReaderScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ReaderScreen( readerViewModel: ReaderViewModel, libraryViewModelProvider: () -> LibraryViewModel, navController: NavController, onOpenFilePicker: () -> Unit, modifier: Modifier = Modifier ): Unit - LongMethod:ReaderSettingsSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderSettingsSheet( uiState: ReaderViewModel.ReaderUiState, onDismiss: () -> Unit, onUpdatePagedMode: (Boolean) -> Unit, onUpdateRtl: (Boolean) -> Unit, onUpdateFontSize: (Float) -> Unit, onUpdateLineHeight: (Float) -> Unit, onUpdateFontFamily: (String) -> Unit, onUpdateMargins: (Int) -> Unit, onUpdateParagraphSpacing: (Float) -> Unit, onUpdateReaderTheme: (ReaderTheme) -> Unit, onUpdateAccentTheme: (AccentTheme) -> Unit, sheetState: SheetState ) + LongMethod:ReaderSettingsSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderSettingsSheet( uiState: ReaderViewModel.ReaderUiState, onDismiss: () -> Unit, onUpdatePagedMode: (Boolean) -> Unit, onUpdateRtl: (Boolean) -> Unit, onUpdateFontSize: (Float) -> Unit, onUpdateLineHeight: (Float) -> Unit, onUpdateFontFamily: (String) -> Unit, onUpdateMargins: (Int) -> Unit, onUpdateParagraphSpacing: (Float) -> Unit, onUpdateReaderTheme: (ReaderTheme) -> Unit, sheetState: SheetState ) LongMethod:ReaderViewModel.kt$ReaderViewModel$fun loadEpubChapter( epubPath: String, href: String, libraryItemId: String? = null, fromBottom: Boolean = false, isSilent: Boolean = false ) LongMethod:ReaderViewModel.kt$ReaderViewModel$private suspend fun handleLoadSuccess( result: ContentResult.Success, libraryItemId: String?, fromBottom: Boolean ) LongMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( readerViewModel: ReaderViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit, backupViewModel: BackupViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel() ) @@ -212,7 +211,7 @@ LongParameterList:ReaderProgressController.kt$ReaderProgressController$( scrollOffset: Float, maxScrollOffset: Float, viewportHeight: Float, index: Int, offsetFraction: Float, elementKey: String, content: ChapterContent?, canScrollForward: Boolean = true, firstVisibleItemSize: Int = 0 ) LongParameterList:ReaderScreen.kt$( uiState: ReaderViewModel.ReaderUiState, readerViewModel: ReaderViewModel, onOpenLibrary: () -> Unit, onOpenLibraryScreen: () -> Unit, onShowChapterList: () -> Unit, onShowSettings: () -> Unit ) LongParameterList:ReaderSettingsSheet.kt$( label: String, value: Float, onValueChange: (Float) -> Unit, valueRange: ClosedFloatingPointRange<Float>, steps: Int, displayValue: String ) - LongParameterList:ReaderSettingsSheet.kt$( uiState: ReaderViewModel.ReaderUiState, onDismiss: () -> Unit, onUpdatePagedMode: (Boolean) -> Unit, onUpdateRtl: (Boolean) -> Unit, onUpdateFontSize: (Float) -> Unit, onUpdateLineHeight: (Float) -> Unit, onUpdateFontFamily: (String) -> Unit, onUpdateMargins: (Int) -> Unit, onUpdateParagraphSpacing: (Float) -> Unit, onUpdateReaderTheme: (ReaderTheme) -> Unit, onUpdateAccentTheme: (AccentTheme) -> Unit, sheetState: SheetState ) + LongParameterList:ReaderSettingsSheet.kt$( uiState: ReaderViewModel.ReaderUiState, onDismiss: () -> Unit, onUpdatePagedMode: (Boolean) -> Unit, onUpdateRtl: (Boolean) -> Unit, onUpdateFontSize: (Float) -> Unit, onUpdateLineHeight: (Float) -> Unit, onUpdateFontFamily: (String) -> Unit, onUpdateMargins: (Int) -> Unit, onUpdateParagraphSpacing: (Float) -> Unit, onUpdateReaderTheme: (ReaderTheme) -> Unit, sheetState: SheetState ) LongParameterList:ReaderStates.kt$( icon: ImageVector, iconTint: androidx.compose.ui.graphics.Color, iconDescription: String, title: String, body: String, action: @Composable (() -> Unit)? = null ) LongParameterList:ReaderTiledImage.kt$( imageUrl: String, pageUrl: String, sliceAspect: Float, sliceCount: Int, backgroundColor: Color, onTap: (() -> Unit)? = null ) LongParameterList:ReaderViewModel.kt$ReaderViewModel$( progress: Int, scrollPosition: Float? = null, index: Int? = null, elementKey: String? = null, offsetFraction: Float? = null, currentChapterUrl: String? = null, forcePersist: Boolean = false ) @@ -533,7 +532,6 @@ MaxLineLength:ExploreGridSection.kt$val shouldLoadMore = uiState.canLoadMore && !uiState.isLoading && uiState.items.isNotEmpty() && lastVisible >= totalItems - 4 MaxLineLength:ExploreScreen.kt$"${uiState.selectedSource} + ${uiState.selectedTags.size} genre${if (uiState.selectedTags.size > 1) "s" else ""}" MaxLineLength:ExploreViewModel.kt$ExploreViewModel$exploreRepository.getNovels(_uiState.value.browseMode, page, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) - MaxLineLength:ExploreViewModel.kt$ExploreViewModel$val novels = exploreRepository.getNovels(_uiState.value.browseMode, 1, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) MaxLineLength:HtmlParser.kt$HtmlParser$Regex(""""url"\s*(?::|,)\s*\[?\s*\d*\s*,?\s*"(https?://[^"]+\.(?:webp|jpg|jpeg|png|gif))""""", RegexOption.IGNORE_CASE) MaxLineLength:HtmlParser.kt$HtmlParser$document.select("img[alt*='credit'], img[alt*='recommend'], img[src*='credit'], img[src*='recommend'], img[alt*='ei0qg'], img[title*='ei0qg']").remove() MaxLineLength:HtmlParser.kt$HtmlParser$if (p.isEmpty() || p.matches(DIGIT_ONLY_REGEX) || CHAPTER_CLEANUP_PATTERN.containsMatchIn(p)) return@filter false diff --git a/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt b/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt index 386ebf46..aaf50598 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/local/LibraryDao.kt @@ -49,6 +49,13 @@ interface LibraryDao { @Query("UPDATE library_items SET readingMode = :readingMode WHERE baseTitle = :baseTitle") suspend fun updateReadingModeByBaseTitle(baseTitle: String, readingMode: ReadingMode) + @Query( + "UPDATE library_items SET coverImageUrl = :cover " + + "WHERE (baseTitle = :displayTitle OR (baseTitle = '' AND title = :displayTitle)) " + + "AND sourceName = :sourceName" + ) + suspend fun updateCoverImageUrl(displayTitle: String, sourceName: String, cover: String): Int + @Query("UPDATE library_items SET isDownloaded = :downloaded, downloadedAt = :timestamp WHERE id = :id") suspend fun setDownloaded(id: String, downloaded: Boolean, timestamp: Long?) diff --git a/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt index a1fbae56..55f3e107 100644 --- a/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt +++ b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt @@ -177,6 +177,11 @@ class LibraryRepository @Inject constructor( } ?: false } ?: false + suspend fun updateCoverImageUrl(displayTitle: String, sourceName: String, cover: String): Boolean = + runRepoCatching("Failed to update cover image URL", false) { + libraryDao.updateCoverImageUrl(displayTitle, sourceName, cover) > 0 + } ?: false + suspend fun updateNovelInfo(itemId: String, baseNovelUrl: String, sourceName: String): Boolean = runRepoCatching("Failed to update novel info", false) { val updatedCount = libraryDao.updateNovelInfo(itemId, baseNovelUrl, sourceName) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt index 9145021d..01913247 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/components/ReaderSettingsSheet.kt @@ -47,7 +47,6 @@ import io.aatricks.easyreader.ui.util.toFontFamily import androidx.compose.ui.unit.dp import io.aatricks.easyreader.R import io.aatricks.easyreader.data.model.ReaderTheme -import io.aatricks.easyreader.ui.theme.AccentTheme import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel @@ -118,27 +117,6 @@ private fun ReaderThemeOption( } } -@Composable -private fun AccentThemeChip( - accentTheme: AccentTheme, - isSelected: Boolean, - onClick: () -> Unit -) { - FilterChip( - selected = isSelected, - onClick = onClick, - label = { Text(accentTheme.displayName) }, - leadingIcon = { - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(accentTheme.previewColor) - ) - }, - colors = settingsChipColors() - ) -} @Composable private fun FontFamilyChip( @@ -173,7 +151,6 @@ fun ReaderSettingsSheet( onUpdateMargins: (Int) -> Unit, onUpdateParagraphSpacing: (Float) -> Unit, onUpdateReaderTheme: (ReaderTheme) -> Unit, - onUpdateAccentTheme: (AccentTheme) -> Unit, sheetState: SheetState ) { ModalBottomSheet( @@ -274,23 +251,6 @@ fun ReaderSettingsSheet( } } - Column(verticalArrangement = Arrangement.spacedBy(EasyReaderSpacing.sm)) { - SettingsSectionLabel(stringResource(R.string.reader_settings_section_accent)) - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(EasyReaderSpacing.xs) - ) { - AccentTheme.entries.forEach { accentTheme -> - AccentThemeChip( - accentTheme = accentTheme, - isSelected = uiState.accentTheme == accentTheme, - onClick = { onUpdateAccentTheme(accentTheme) } - ) - } - } - } SettingSlider( label = stringResource(R.string.reader_slider_font_size), diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt index ec308ad7..cd88416a 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/reader/ReaderScreen.kt @@ -274,7 +274,6 @@ fun ReaderScreen( onUpdateMargins = { readerViewModel.updateMargins(it) }, onUpdateParagraphSpacing = { readerViewModel.updateParagraphSpacing(it) }, onUpdateReaderTheme = { readerViewModel.updateReaderTheme(it) }, - onUpdateAccentTheme = { readerViewModel.updateAccentTheme(it) }, sheetState = settingsSheetState ) } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt index a19d8057..bf86dfad 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModel.kt @@ -26,6 +26,13 @@ import android.util.Log import io.aatricks.easyreader.ui.screens.DrawerNovelSections import io.aatricks.easyreader.ui.screens.buildDrawerNovelSections import kotlinx.coroutines.Dispatchers +import io.aatricks.easyreader.data.model.libraryDisplayTitle +import io.aatricks.easyreader.data.model.libraryNovelKey +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll @HiltViewModel class LibraryViewModel @Inject constructor( @@ -70,6 +77,49 @@ class LibraryViewModel @Inject constructor( init { _collapsedSources.value = repository.loadCollapsedSources() + backfillMissingCovers() + } + + private fun backfillMissingCovers() { + if (coversBackfillAttempted.compareAndSet(false, true)) { + viewModelScope.launch(defaultDispatcher) { + runCatching { + val items = repository.getAllItemsSnapshot() + val itemsToBackfill = items.filter { + it.coverImageUrl.isBlank() && it.contentType == ContentType.WEB + } + if (itemsToBackfill.isEmpty()) return@launch + + val grouped = itemsToBackfill.groupBy { it.libraryNovelKey() } + val semaphore = Semaphore(BACKFILL_CONCURRENCY) + val jobs = grouped.values.map { novelGroup -> + async { + semaphore.withPermit { + val firstItem = novelGroup.first() + val url = firstItem.baseNovelUrl.ifBlank { firstItem.url } + val sourceName = firstItem.sourceName + val displayTitle = firstItem.libraryDisplayTitle() + + runCatching { + val knownSources = exploreRepository.getSourceNames() + val details = if (knownSources.contains(sourceName)) { + exploreRepository.getNovelDetails(url, sourceName) + } else { + exploreRepository.getNovelDetailsByUrl(url) + } + + val coverUrl = details?.coverUrl + if (!coverUrl.isNullOrBlank()) { + repository.updateCoverImageUrl(displayTitle, sourceName, coverUrl) + } + } + } + } + } + jobs.awaitAll() + } + } + } } fun reconcileDownloadedItemsOnDemand(): Unit { @@ -686,6 +736,8 @@ class LibraryViewModel @Inject constructor( companion object { var defaultDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default var isUnderTest: Boolean = false + val coversBackfillAttempted = AtomicBoolean(false) + private const val BACKFILL_CONCURRENCY = 3 } } diff --git a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt index ff284aa6..c5882bec 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ReaderViewModel.kt @@ -428,11 +428,6 @@ class ReaderViewModel @Inject constructor( updateState { it.copy(readerTheme = newTheme, toastMessage = "Theme: $label") } } - fun updateAccentTheme(newAccentTheme: AccentTheme) { - if (preferencesManager.accentTheme == newAccentTheme.name) return - preferencesManager.accentTheme = newAccentTheme.name - updateState { it.copy(accentTheme = newAccentTheme, toastMessage = "Accent: ${newAccentTheme.displayName}") } - } fun clearToast() { updateState { it.copy(toastMessage = null) } diff --git a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt index 507846e7..660548d7 100644 --- a/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt +++ b/app/src/test/java/io/aatricks/easyreader/ui/viewmodel/LibraryViewModelTest.kt @@ -41,6 +41,7 @@ class LibraryViewModelTest { Dispatchers.setMain(testDispatcher) LibraryViewModel.isUnderTest = true LibraryViewModel.defaultDispatcher = testDispatcher + LibraryViewModel.coversBackfillAttempted.set(false) whenever(libraryRepository.libraryItems).thenReturn(MutableStateFlow(emptyList())) whenever(libraryRepository.loadCollapsedSources()).thenReturn(emptySet()) @@ -49,6 +50,7 @@ class LibraryViewModelTest { runTest { whenever(libraryRepository.clearUpdateIndicator(any())).thenReturn(false) whenever(libraryRepository.updateItem(any())).thenReturn(true) + whenever(libraryRepository.getAllItemsSnapshot()).thenReturn(emptyList()) } reconciler = DownloadStatusReconciler(libraryRepository) @@ -849,6 +851,140 @@ class LibraryViewModelTest { ) assertNull(viewModel.uiState.value.error) } + + @Test + fun `backfillMissingCovers updates blank covers from explore details once per novel`() = runTest { + LibraryViewModel.coversBackfillAttempted.set(false) + val item1 = LibraryItem( + id = "id-1", + title = "Novel 1 - Chapter 1", + url = "https://example.com/novel-1/chapter-1", + baseTitle = "Novel 1", + baseNovelUrl = "https://example.com/novel-1", + sourceName = "Source1", + contentType = ContentType.WEB, + coverImageUrl = "" + ) + val item2 = LibraryItem( + id = "id-2", + title = "Novel 1 - Chapter 2", + url = "https://example.com/novel-1/chapter-2", + baseTitle = "Novel 1", + baseNovelUrl = "https://example.com/novel-1", + sourceName = "Source1", + contentType = ContentType.WEB, + coverImageUrl = "" + ) + + whenever(libraryRepository.getAllItemsSnapshot()).thenReturn(listOf(item1, item2)) + whenever(exploreRepository.getSourceNames()).thenReturn(listOf("Source1")) + whenever(exploreRepository.getNovelDetails("https://example.com/novel-1", "Source1")).thenReturn( + ExploreItem( + title = "Novel 1", + url = "https://example.com/novel-1", + source = "Source1", + coverUrl = "https://example.com/novel-1/cover.jpg" + ) + ) + whenever(libraryRepository.updateCoverImageUrl(any(), any(), any())).thenReturn(true) + + val testViewModel = LibraryViewModel( + libraryRepository, + contentRepository, + exploreRepository, + io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), + reconciler + ) + advanceUntilIdle() + + verify(exploreRepository, times(1)).getNovelDetails("https://example.com/novel-1", "Source1") + verify(libraryRepository, times(1)).updateCoverImageUrl("Novel 1", "Source1", "https://example.com/novel-1/cover.jpg") + } + + @Test + fun `backfillMissingCovers does not touch items that already have covers`() = runTest { + LibraryViewModel.coversBackfillAttempted.set(false) + val itemWithCover = LibraryItem( + id = "id-1", + title = "Novel 1 - Chapter 1", + url = "https://example.com/novel-1/chapter-1", + baseTitle = "Novel 1", + baseNovelUrl = "https://example.com/novel-1", + sourceName = "Source1", + contentType = ContentType.WEB, + coverImageUrl = "https://example.com/novel-1/cover.jpg" + ) + + whenever(libraryRepository.getAllItemsSnapshot()).thenReturn(listOf(itemWithCover)) + + val testViewModel = LibraryViewModel( + libraryRepository, + contentRepository, + exploreRepository, + io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), + reconciler + ) + advanceUntilIdle() + + verify(exploreRepository, never()).getNovelDetails(any(), any()) + verify(libraryRepository, never()).updateCoverImageUrl(any(), any(), any()) + } + + @Test + fun `backfillMissingCovers failure for one novel does not prevent others from backfilling`() = runTest { + LibraryViewModel.coversBackfillAttempted.set(false) + val item1 = LibraryItem( + id = "id-1", + title = "Novel 1 - Chapter 1", + url = "https://example.com/novel-1/chapter-1", + baseTitle = "Novel 1", + baseNovelUrl = "https://example.com/novel-1", + sourceName = "Source1", + contentType = ContentType.WEB, + coverImageUrl = "" + ) + val item2 = LibraryItem( + id = "id-2", + title = "Novel 2 - Chapter 1", + url = "https://example.com/novel-2/chapter-1", + baseTitle = "Novel 2", + baseNovelUrl = "https://example.com/novel-2", + sourceName = "Source2", + contentType = ContentType.WEB, + coverImageUrl = "" + ) + + whenever(libraryRepository.getAllItemsSnapshot()).thenReturn(listOf(item1, item2)) + whenever(exploreRepository.getSourceNames()).thenReturn(listOf("Source1", "Source2")) + + // Novel 1 fails + whenever(exploreRepository.getNovelDetails("https://example.com/novel-1", "Source1")) + .thenThrow(RuntimeException("Network error")) + + // Novel 2 succeeds + whenever(exploreRepository.getNovelDetails("https://example.com/novel-2", "Source2")).thenReturn( + ExploreItem( + title = "Novel 2", + url = "https://example.com/novel-2", + source = "Source2", + coverUrl = "https://example.com/novel-2/cover.jpg" + ) + ) + whenever(libraryRepository.updateCoverImageUrl(any(), any(), any())).thenReturn(true) + + val testViewModel = LibraryViewModel( + libraryRepository, + contentRepository, + exploreRepository, + io.aatricks.easyreader.work.NoOpChapterDownloadQueue(), + reconciler + ) + advanceUntilIdle() + + verify(exploreRepository, times(1)).getNovelDetails("https://example.com/novel-1", "Source1") + verify(exploreRepository, times(1)).getNovelDetails("https://example.com/novel-2", "Source2") + verify(libraryRepository, times(1)).updateCoverImageUrl("Novel 2", "Source2", "https://example.com/novel-2/cover.jpg") + } } private data class RecordedEnqueue(val url: String, val replaceExisting: Boolean) From 6534471b44dd9e119c9be5907b5723d22b549181 Mon Sep 17 00:00:00 2001 From: Aatricks Date: Wed, 8 Jul 2026 00:56:02 -0400 Subject: [PATCH 8/8] Show cover art on library novel group cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cover thumbnail was added to LibraryItemCard, but the library screen actually renders novels through NovelGroupHeader in LibraryScreenListSections — LibraryItemCard is only referenced by its own previews. Add the same Coil thumbnail (shared request builder) to the group header. --- .../library/LibraryScreenListSections.kt | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt index 90e95078..8d679df5 100644 --- a/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt +++ b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenListSections.kt @@ -66,7 +66,12 @@ import io.aatricks.easyreader.data.model.ContentElement import io.aatricks.easyreader.data.model.ContentResult import io.aatricks.easyreader.data.model.ContentType import io.aatricks.easyreader.data.model.LibraryItem +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage import io.aatricks.easyreader.ui.components.ChapterSummaryDropdown +import io.aatricks.easyreader.ui.components.rememberLibraryCoverImageRequest import io.aatricks.easyreader.ui.theme.EasyReaderMotion import io.aatricks.easyreader.ui.theme.EasyReaderSpacing import io.aatricks.easyreader.ui.viewmodel.LibraryViewModel @@ -348,7 +353,24 @@ private fun NovelGroupHeader( }, onLongClick = onToggleSelection ) { - Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val coverItem = items.firstOrNull { it.coverImageUrl.isNotBlank() } + if (coverItem != null) { + AsyncImage( + model = rememberLibraryCoverImageRequest(coverItem), + contentDescription = null, + modifier = Modifier + .width(48.dp) + .aspectRatio(GROUP_COVER_ASPECT_RATIO) + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(EasyReaderSpacing.sm)) + } + Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.titleMedium, @@ -376,6 +398,7 @@ private fun NovelGroupHeader( } } } + } } if (!isSelectionMode && updateItem != null) { @@ -619,3 +642,5 @@ private fun NovelChapterList( } } } + +private const val GROUP_COVER_ASPECT_RATIO = 2f / 3f