diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index c073f6ca..13e0486e 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 = "" )
@@ -75,10 +75,11 @@
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
- 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
@@ -109,19 +110,19 @@
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()
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)
@@ -138,7 +139,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
@@ -172,12 +173,13 @@
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() )
+ 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 )
@@ -188,7 +190,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 )
@@ -209,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 )
@@ -427,7 +429,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
@@ -531,9 +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: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 +746,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/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/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 @@
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/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/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/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/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/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/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/data/repository/LibraryRepository.kt b/app/src/main/java/io/aatricks/easyreader/data/repository/LibraryRepository.kt
index 65b35bad..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
@@ -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)
@@ -175,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/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/ChapterListSheet.kt b/app/src/main/java/io/aatricks/easyreader/ui/components/ChapterListSheet.kt
index 1b2ace67..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) }
@@ -167,7 +168,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 +185,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 +227,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))
@@ -312,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/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 69403a33..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
@@ -25,12 +25,53 @@ 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
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
+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.
@@ -63,6 +104,7 @@ fun LibraryItemCard(
onResetProgress: (() -> 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,8 +153,14 @@ fun LibraryItemCard(
)
.combinedClickable(
onClick = onClick,
- onLongClick = onLongClick
- ),
+ onLongClick = {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ onLongClick()
+ }
+ )
+ .semantics {
+ selected = isSelected
+ },
colors = CardDefaults.cardColors(
containerColor = backgroundColor
),
@@ -120,188 +168,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.size(36.dp)
+
+ // Current reading badge
+ if (isCurrent) {
+ Badge(
+ containerColor = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = EasyReaderSpacing.xs)
) {
- 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 = EasyReaderSpacing.xs)
+ .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 = EasyReaderSpacing.xs)
+ .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(EasyReaderSpacing.xs))
+ 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
+ )
+ }
+ }
}
}
}
@@ -315,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 603a6081..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
@@ -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()) {
@@ -140,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() }
@@ -156,7 +158,7 @@ fun BottomNavigationBar(
verticalAlignment = Alignment.CenterVertically
) {
Text(
- text = "Progress",
+ text = "Chapter progress",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -168,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
)
@@ -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/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 9e9c510a..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
@@ -37,14 +37,16 @@ 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
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
@@ -82,7 +84,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) {
@@ -111,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(
@@ -166,7 +151,6 @@ fun ReaderSettingsSheet(
onUpdateMargins: (Int) -> Unit,
onUpdateParagraphSpacing: (Float) -> Unit,
onUpdateReaderTheme: (ReaderTheme) -> Unit,
- onUpdateAccentTheme: (AccentTheme) -> Unit,
sheetState: SheetState
) {
ModalBottomSheet(
@@ -267,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),
@@ -345,7 +312,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/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/explore/ExploreScreen.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/explore/ExploreScreen.kt
index 5e97eb8f..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
@@ -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,
@@ -337,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/LibraryDrawerContent.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryDrawerContent.kt
index 94e51a33..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(
@@ -119,10 +126,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()
@@ -150,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
+ )
+ }
}
}
}
@@ -198,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,
@@ -219,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)
@@ -239,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/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/LibraryScreenEpubSections.kt b/app/src/main/java/io/aatricks/easyreader/ui/screens/library/LibraryScreenEpubSections.kt
index fb4c1c6a..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
@@ -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(
@@ -160,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
}
@@ -191,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 6cff71af..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
@@ -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
@@ -46,6 +48,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
@@ -63,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
@@ -281,12 +289,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
@@ -339,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,
@@ -348,7 +379,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
@@ -367,6 +398,7 @@ private fun NovelGroupHeader(
}
}
}
+ }
}
if (!isSelectionMode && updateItem != null) {
@@ -437,7 +469,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 +479,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))
}
@@ -505,7 +543,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" },
@@ -528,7 +566,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,
@@ -604,3 +642,5 @@ private fun NovelChapterList(
}
}
}
+
+private const val GROUP_COVER_ASPECT_RATIO = 2f / 3f
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..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
@@ -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
@@ -36,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
@@ -52,8 +54,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 +69,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 +136,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 +311,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 +408,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 +436,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(
@@ -669,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 6ecc42f9..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,9 @@ 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
import io.aatricks.easyreader.data.model.ContentElement
import io.aatricks.easyreader.ui.viewmodel.ReaderViewModel
@@ -96,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
@@ -118,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"
@@ -154,11 +157,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 +195,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 +233,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 +286,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 2122effa..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
@@ -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
@@ -77,6 +78,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("") }
@@ -96,10 +98,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 ?: ""
@@ -119,26 +117,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 +149,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,8 +229,9 @@ fun ReaderScreen(
) {
@Suppress("UnusedMaterial3ScaffoldPaddingParameter")
Scaffold(
- containerColor = Color.Black,
- contentWindowInsets = WindowInsets(0, 0, 0, 0)
+ containerColor = if (uiState.content != null) Color.Black else MaterialTheme.colorScheme.background,
+ 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
@@ -267,7 +274,6 @@ fun ReaderScreen(
onUpdateMargins = { readerViewModel.updateMargins(it) },
onUpdateParagraphSpacing = { readerViewModel.updateParagraphSpacing(it) },
onUpdateReaderTheme = { readerViewModel.updateReaderTheme(it) },
- onUpdateAccentTheme = { readerViewModel.updateAccentTheme(it) },
sheetState = settingsSheetState
)
}
@@ -284,6 +290,11 @@ fun ReaderScreen(
readerViewModel.navigateToChapter(url, title)
}
},
+ onDownloadRemoved = {
+ scope.launch {
+ snackbarHostState.showSnackbar("Chapter download removed")
+ }
+ },
sheetState = bottomSheetState
)
}
@@ -316,7 +327,7 @@ private fun CloudflareDialog(
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(16.dp),
+ .padding(EasyReaderSpacing.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -343,7 +354,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
)
@@ -353,7 +364,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)
@@ -411,7 +422,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")
}
@@ -422,8 +433,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(
@@ -435,7 +446,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")
}
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/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..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,7 +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
@@ -67,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()
@@ -78,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) }
@@ -165,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",
@@ -200,9 +315,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 +339,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 +374,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) {
@@ -622,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/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt b/app/src/main/java/io/aatricks/easyreader/ui/viewmodel/ExploreViewModel.kt
index 1a6b12af..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
@@ -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 {
@@ -74,18 +76,24 @@ 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()
+ canLoadMore = outcome.items.isNotEmpty(),
+ 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) }
}
}
}
@@ -120,7 +128,8 @@ class ExploreViewModel @Inject constructor(
selectedItem = null,
selectedItemDetails = null,
isFetchingDetails = false,
- canLoadMore = true
+ canLoadMore = true,
+ hasError = false
)
}
val tags = exploreRepository.getTags(sourceName)
@@ -131,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(
@@ -141,12 +156,13 @@ class ExploreViewModel @Inject constructor(
availableTags = tags,
isSearching = searchQuery.isNotBlank(),
canLoadMore = novels.isNotEmpty(),
- searchFailures = failures
+ searchFailures = if (searchQuery.isNotBlank()) failures else emptyList(),
+ hasError = novels.isEmpty() && 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) }
}
}
}
@@ -160,12 +176,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 outcome = exploreRepository.getNovelsDetailed(
+ _uiState.value.browseMode,
+ 1,
+ _uiState.value.selectedSource,
+ newTags.toList()
+ )
+ updateState {
+ it.copy(
+ items = outcome.items,
+ isLoading = false,
+ canLoadMore = outcome.items.isNotEmpty(),
+ 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) }
}
}
}
@@ -174,12 +210,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 outcome = exploreRepository.getNovelsDetailed(
+ _uiState.value.browseMode,
+ 1,
+ _uiState.value.selectedSource,
+ emptyList()
+ )
+ updateState {
+ it.copy(
+ items = outcome.items,
+ isLoading = false,
+ canLoadMore = outcome.items.isNotEmpty(),
+ 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) }
}
}
}
@@ -202,7 +258,8 @@ class ExploreViewModel @Inject constructor(
isLoading = true,
page = 1,
canLoadMore = true,
- searchFailures = emptyList()
+ searchFailures = emptyList(),
+ hasError = false
)
}
val outcome = exploreRepository.searchNovelsDetailed(
@@ -215,17 +272,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 +382,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..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 {
@@ -122,7 +172,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 +216,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,
@@ -241,8 +293,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) {
@@ -250,8 +305,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,
@@ -261,7 +317,8 @@ class LibraryViewModel @Inject constructor(
baseTitle = item.title,
baseNovelUrl = item.url,
sourceName = item.source,
- totalChapters = item.chapterCount
+ totalChapters = item.chapterCount,
+ coverImageUrl = coverImageUrl
)
}
}
@@ -292,7 +349,8 @@ class LibraryViewModel @Inject constructor(
baseTitle = item.title,
baseNovelUrl = item.url,
sourceName = item.source,
- totalChapters = item.chapterCount
+ totalChapters = item.chapterCount,
+ coverImageUrl = item.coverUrl.orEmpty()
)
}
@@ -341,52 +399,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 +486,7 @@ class LibraryViewModel @Inject constructor(
baseNovelUrl = url,
sourceName = if (url.startsWith("http")) "Web" else "File"
)
+ return baseTitle.ifBlank { fullTitle }
}
fun openNewChapter(
@@ -648,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 78bb4c80..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) }
@@ -844,7 +839,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/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/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/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))
+ }
+}
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)
+ }
+}
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..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
@@ -20,6 +21,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
@@ -46,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)
}
@@ -120,4 +124,30 @@ class ExploreViewModelTest {
assertNull(state.selectedItemDetails)
assertFalse(state.isSearching)
}
+
+ @Test
+ 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.getNovelsDetailed(any(), any(), anyOrNull(), any())).thenReturn(
+ SearchOutcome(
+ emptyList(),
+ listOf(
+ SourceFailure("NovelFire", offline.message, offline),
+ SourceFailure("MangaBat", offline.message, offline)
+ )
+ )
+ )
+
+ 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..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)
@@ -237,6 +239,7 @@ class LibraryViewModelTest {
any(),
any(),
any(),
+ any(),
any()
)
).thenReturn(createdItem)
@@ -255,7 +258,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 +341,7 @@ class LibraryViewModelTest {
any(),
any(),
any(),
+ any(),
any()
)
).thenReturn(
@@ -395,7 +400,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 +510,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",
@@ -746,6 +751,240 @@ 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(),
+ 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)
+ }
+
+ @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)
+ }
+
+ @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)
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)