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)