Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
parent
c0d0e57e79
commit
b20ade9946
247 changed files with 43321 additions and 7087 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.ReaderSearchOptions
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ sealed interface LibraryAction {
|
|||
data class SortChanged(val sortOrder: SortOrder) : LibraryAction
|
||||
data class FiltersChanged(val filters: LibraryFilters) : LibraryAction
|
||||
data class BookSelectionToggled(val bookId: String) : LibraryAction
|
||||
data class BookSelectionReplaced(val bookIds: Set<String>) : LibraryAction
|
||||
data object SelectionCleared : LibraryAction
|
||||
data class ShelfSelectionToggled(val shelfId: String) : LibraryAction
|
||||
data object ShelfSelectionCleared : LibraryAction
|
||||
|
|
@ -24,8 +26,18 @@ sealed interface ReaderAction {
|
|||
data class GoToProgress(val progress: Float) : ReaderAction
|
||||
data class GoToChapter(val chapterIndex: Int) : ReaderAction
|
||||
data class GoToLocator(val locator: ReaderLocator) : ReaderAction
|
||||
data class JumpToPage(val pageIndex: Int) : ReaderAction
|
||||
data class JumpToPageNumber(val pageNumber: Int) : ReaderAction
|
||||
data class JumpToChapter(val chapterIndex: Int) : ReaderAction
|
||||
data class JumpToLocator(val locator: ReaderLocator) : ReaderAction
|
||||
data class VisiblePageChanged(val pageIndex: Int, val locator: ReaderLocator? = null) : ReaderAction
|
||||
data class GoToSearchResult(val resultIndex: Int) : ReaderAction
|
||||
data class JumpToSearchResult(val resultIndex: Int) : ReaderAction
|
||||
data object JumpToNextSearchResult : ReaderAction
|
||||
data object JumpToPreviousSearchResult : ReaderAction
|
||||
data object JumpBack : ReaderAction
|
||||
data object JumpForward : ReaderAction
|
||||
data object JumpHistoryCleared : ReaderAction
|
||||
data class SearchChanged(val query: String) : ReaderAction
|
||||
data object SearchOpened : ReaderAction
|
||||
data object SearchClosed : ReaderAction
|
||||
|
|
@ -71,11 +83,14 @@ sealed interface AppAction {
|
|||
data object AllTabsClosed : AppAction
|
||||
data class HomePinToggled(val bookId: String) : AppAction
|
||||
data class LibraryPinToggled(val bookId: String) : AppAction
|
||||
data class ReaderDefaultSettingsChanged(val settings: ReaderSettings) : AppAction
|
||||
data class PdfReaderDefaultSettingsChanged(val settings: ReaderSettings) : AppAction
|
||||
data class ReaderToolbarPreferencesChanged(val preferences: ReaderToolbarPreferences) : AppAction
|
||||
data class ReaderToolVisibilityChanged(val tool: ReaderTool, val hidden: Boolean) : AppAction
|
||||
data class ReaderToolPlacementChanged(val tool: ReaderTool, val bottom: Boolean) : AppAction
|
||||
data class ReaderToolOrderChanged(val toolOrder: List<ReaderTool>) : AppAction
|
||||
data class ReaderHighlightPaletteChanged(val palette: ReaderHighlightPalette) : AppAction
|
||||
data class PdfHighlighterPaletteChanged(val palette: SharedPdfHighlighterPalette) : AppAction
|
||||
data class ReaderTtsReplacementPreferencesChanged(
|
||||
val preferences: ReaderTtsReplacementPreferences,
|
||||
) : AppAction
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
data class BannerMessage(
|
||||
val message: String,
|
||||
|
|
@ -104,7 +106,7 @@ data class SharedReaderScreenState(
|
|||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||
val libraryFilters: LibraryFilters = LibraryFilters(),
|
||||
val recentFilesLimit: Int = 0,
|
||||
val isTabsEnabled: Boolean = false,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val openTabs: List<BookItem> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
|
|
@ -117,9 +119,12 @@ data class SharedReaderScreenState(
|
|||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: Color? = null,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val allTags: List<Tag> = emptyList(),
|
||||
val showTagSelectionDialogFor: Set<String> = emptySet(),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,43 @@ data class FileTypeCapability(
|
|||
}
|
||||
|
||||
object SharedFileCapabilities {
|
||||
private val codeOrDataExtensions = setOf(
|
||||
"csv",
|
||||
"tsv",
|
||||
"json",
|
||||
"xml",
|
||||
"log",
|
||||
"java",
|
||||
"kt",
|
||||
"py",
|
||||
"js",
|
||||
"cpp",
|
||||
"c",
|
||||
"cs",
|
||||
"rb",
|
||||
"go"
|
||||
)
|
||||
|
||||
private val manualOnlyReaderMimeTypes = setOf(
|
||||
"text/csv",
|
||||
"text/comma-separated-values",
|
||||
"text/tab-separated-values",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"text/x-java-source",
|
||||
"text/x-python",
|
||||
"text/x-kotlin",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"text/x-c",
|
||||
"text/x-c++",
|
||||
"text/x-csharp",
|
||||
"text/x-ruby",
|
||||
"text/x-go",
|
||||
"text/x-log"
|
||||
)
|
||||
|
||||
val all: List<FileTypeCapability> = listOf(
|
||||
FileTypeCapability(
|
||||
type = FileType.EPUB,
|
||||
|
|
@ -122,6 +159,13 @@ object SharedFileCapabilities {
|
|||
extensions = setOf("fodt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.PPTX,
|
||||
displayName = "PPTX",
|
||||
extensions = setOf("pptx"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = null
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -129,6 +173,23 @@ object SharedFileCapabilities {
|
|||
private val typesByExtension: Map<String, FileType> = all
|
||||
.flatMap { capability -> capability.extensions.map { it.lowercase() to capability.type } }
|
||||
.toMap()
|
||||
private val mimeTypesByType: Map<FileType, String> = mapOf(
|
||||
FileType.PDF to "application/pdf",
|
||||
FileType.EPUB to "application/epub+zip",
|
||||
FileType.MOBI to "application/x-mobipocket-ebook",
|
||||
FileType.MD to "text/markdown",
|
||||
FileType.TXT to "text/plain",
|
||||
FileType.HTML to "text/html",
|
||||
FileType.FB2 to "application/x-fictionbook+xml",
|
||||
FileType.CBZ to "application/zip",
|
||||
FileType.CBR to "application/zip",
|
||||
FileType.CB7 to "application/zip",
|
||||
FileType.DOCX to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
FileType.PPTX to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
FileType.ODT to "application/vnd.oasis.opendocument.text",
|
||||
FileType.FODT to "application/x-vnd.oasis.opendocument.text-flat-xml"
|
||||
)
|
||||
val knownFileTypes: Set<FileType> = all.mapTo(mutableSetOf()) { it.type }
|
||||
|
||||
fun capabilityFor(type: FileType): FileTypeCapability? {
|
||||
return capabilitiesByType[type]
|
||||
|
|
@ -138,12 +199,63 @@ object SharedFileCapabilities {
|
|||
return capabilityFor(type)?.displayName ?: type.name
|
||||
}
|
||||
|
||||
fun primaryExtensionFor(type: FileType): String? {
|
||||
return capabilityFor(type)?.extensions?.firstOrNull()
|
||||
}
|
||||
|
||||
fun mimeTypeFor(type: FileType): String? {
|
||||
return mimeTypesByType[type]
|
||||
}
|
||||
|
||||
fun fileTypeForName(fileName: String): FileType {
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.lowercase()
|
||||
return typesByExtension[extension] ?: FileType.UNKNOWN
|
||||
return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN
|
||||
}
|
||||
|
||||
fun resolveFileTypeForName(fileName: String?): FileType? {
|
||||
val normalized = fileName?.normalizedFileName()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val effectiveName = normalized.withTransparentTextSuffix()
|
||||
return fileTypeForEffectiveName(effectiveName)
|
||||
}
|
||||
|
||||
fun isCodeOrDataFileName(fileName: String): Boolean {
|
||||
return fileName.normalizedFileName()
|
||||
.withTransparentTextSuffix()
|
||||
.extensionAfterLastDot() in codeOrDataExtensions
|
||||
}
|
||||
|
||||
fun isManualOnlyReaderFileName(fileName: String?): Boolean {
|
||||
return fileName?.let(::isCodeOrDataFileName) ?: false
|
||||
}
|
||||
|
||||
fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
|
||||
val normalized = mimeType?.lowercase() ?: return false
|
||||
return normalized in manualOnlyReaderMimeTypes
|
||||
}
|
||||
|
||||
fun isLocalFolderSyncEligibleFile(name: String, mimeType: String?): Boolean {
|
||||
if (isManualOnlyReaderFileName(name)) return false
|
||||
if (resolveFileTypeForName(name) != null) return true
|
||||
return !isManualOnlyReaderMimeType(mimeType)
|
||||
}
|
||||
|
||||
fun fileExtensionSuffixForName(fileName: String?): String? {
|
||||
val normalized = fileName?.normalizedFileName()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val effectiveName = normalized.withTransparentTextSuffix()
|
||||
val effectiveSuffix = when {
|
||||
effectiveName.endsWith(".fb2.zip") -> ".fb2.zip"
|
||||
effectiveName.endsWith(".markdown") -> ".markdown"
|
||||
effectiveName.endsWith(".xhtml") -> ".xhtml"
|
||||
effectiveName.extensionAfterLastDot() != null && resolveFileTypeForName(effectiveName) != null -> {
|
||||
".${effectiveName.extensionAfterLastDot()}"
|
||||
}
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
return if (effectiveName != normalized && normalized.endsWith(".txt")) {
|
||||
"$effectiveSuffix.txt"
|
||||
} else {
|
||||
effectiveSuffix
|
||||
}
|
||||
}
|
||||
|
||||
fun surfaceFor(type: FileType, platform: ReaderPlatform): ReaderFeatureSurface? {
|
||||
|
|
@ -177,4 +289,30 @@ object SharedFileCapabilities {
|
|||
.filter { it.isReadableOnAndroid && !it.isReadableOnDesktop }
|
||||
.map { it.type }
|
||||
}
|
||||
|
||||
private fun fileTypeForEffectiveName(fileName: String): FileType? {
|
||||
if (fileName.endsWith(".fb2.zip")) return FileType.FB2
|
||||
val extension = fileName.extensionAfterLastDot() ?: return null
|
||||
if (extension in codeOrDataExtensions) return FileType.HTML
|
||||
return typesByExtension[extension]
|
||||
}
|
||||
|
||||
private fun String.normalizedFileName(): String {
|
||||
return trim()
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
private fun String.withTransparentTextSuffix(): String {
|
||||
if (!endsWith(".txt")) return this
|
||||
val innerName = removeSuffix(".txt")
|
||||
if (innerName.isBlank() || !innerName.contains('.')) return this
|
||||
return if (fileTypeForEffectiveName(innerName) != null) innerName else this
|
||||
}
|
||||
|
||||
private fun String.extensionAfterLastDot(): String? {
|
||||
val dotIndex = lastIndexOf('.')
|
||||
return if (dotIndex > 0 && dotIndex < lastIndex) substring(dotIndex + 1) else null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
enum class SharedImportDecisionStatus {
|
||||
IMPORTABLE,
|
||||
DUPLICATE,
|
||||
UNSUPPORTED
|
||||
}
|
||||
|
||||
data class SharedImportDecision(
|
||||
val file: ImportedBookFile,
|
||||
val id: String,
|
||||
val type: FileType,
|
||||
val status: SharedImportDecisionStatus
|
||||
)
|
||||
|
||||
data class SharedImportPlan(
|
||||
val decisions: List<SharedImportDecision>,
|
||||
val importedBooks: List<BookItem>
|
||||
) {
|
||||
val supportedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filterNot { it.status == SharedImportDecisionStatus.UNSUPPORTED }
|
||||
.map { it.file }
|
||||
|
||||
val importableFiles: List<ImportedBookFile>
|
||||
get() = supportedFiles
|
||||
|
||||
val importedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.IMPORTABLE }
|
||||
.map { it.file }
|
||||
|
||||
val duplicateFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.DUPLICATE }
|
||||
.map { it.file }
|
||||
|
||||
val unsupportedFiles: List<ImportedBookFile>
|
||||
get() = decisions
|
||||
.filter { it.status == SharedImportDecisionStatus.UNSUPPORTED }
|
||||
.map { it.file }
|
||||
|
||||
val importedCount: Int get() = importedBooks.size
|
||||
val duplicateCount: Int get() = duplicateFiles.size
|
||||
val unsupportedCount: Int get() = unsupportedFiles.size
|
||||
}
|
||||
|
||||
data class SharedImportOutcomeCounts(
|
||||
val addedCount: Int = 0,
|
||||
val duplicateCount: Int = 0,
|
||||
val unsupportedCount: Int = 0,
|
||||
val failedCount: Int = 0
|
||||
)
|
||||
|
||||
data class SharedImportFeedback(
|
||||
val message: String,
|
||||
val isError: Boolean
|
||||
)
|
||||
|
||||
object SharedImportPlanner {
|
||||
fun plan(
|
||||
files: List<ImportedBookFile>,
|
||||
existingBookIds: Set<String>,
|
||||
platform: ReaderPlatform,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedImportPlan {
|
||||
val seenIds = existingBookIds.toMutableSet()
|
||||
val decisions = files.map { file ->
|
||||
val id = stableImportId(file)
|
||||
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||
val status = when {
|
||||
!SharedFileCapabilities.canOpen(type, platform) -> SharedImportDecisionStatus.UNSUPPORTED
|
||||
!seenIds.add(id) -> SharedImportDecisionStatus.DUPLICATE
|
||||
else -> SharedImportDecisionStatus.IMPORTABLE
|
||||
}
|
||||
SharedImportDecision(
|
||||
file = file,
|
||||
id = id,
|
||||
type = type,
|
||||
status = status
|
||||
)
|
||||
}
|
||||
val importedBooks = decisions.mapIndexedNotNull { index, decision ->
|
||||
if (decision.status != SharedImportDecisionStatus.IMPORTABLE) return@mapIndexedNotNull null
|
||||
val file = decision.file
|
||||
BookItem(
|
||||
id = decision.id,
|
||||
path = file.localPath ?: file.uriString,
|
||||
type = decision.type,
|
||||
displayName = file.name,
|
||||
timestamp = nowMillis + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder,
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
return SharedImportPlan(decisions, importedBooks)
|
||||
}
|
||||
|
||||
fun feedbackForCounts(
|
||||
counts: SharedImportOutcomeCounts,
|
||||
importedMessage: String,
|
||||
duplicateMessage: String,
|
||||
unsupportedMessage: String,
|
||||
failedMessage: String
|
||||
): SharedImportFeedback {
|
||||
val message = when {
|
||||
counts.addedCount > 0 -> importedMessage
|
||||
counts.duplicateCount > 0 -> duplicateMessage
|
||||
counts.unsupportedCount > 0 -> unsupportedMessage
|
||||
else -> failedMessage
|
||||
}
|
||||
return SharedImportFeedback(
|
||||
message = message,
|
||||
isError = counts.addedCount == 0 && counts.duplicateCount == 0
|
||||
)
|
||||
}
|
||||
|
||||
fun stableImportId(file: ImportedBookFile): String {
|
||||
return file.id ?: file.localPath ?: file.uriString ?: file.name
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
enum class FileType {
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, UNKNOWN
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, PPTX, UNKNOWN
|
||||
}
|
||||
|
||||
val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX)
|
||||
|
||||
val EPUB_READER_FILE_TYPES = setOf(
|
||||
FileType.EPUB,
|
||||
|
|
@ -68,7 +69,7 @@ data class SyncedFolder(
|
|||
val uriString: String,
|
||||
val name: String,
|
||||
val lastScanTime: Long,
|
||||
val allowedFileTypes: Set<FileType> = FileType.entries.toSet()
|
||||
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes
|
||||
)
|
||||
|
||||
data class BookItem(
|
||||
|
|
@ -80,18 +81,27 @@ data class BookItem(
|
|||
val coverImagePath: String? = null,
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null,
|
||||
val progressPercentage: Float? = null,
|
||||
val isRecent: Boolean = true,
|
||||
val fileSize: Long = 0L,
|
||||
val fileContentModifiedTimestamp: Long = 0L,
|
||||
val sourceFolder: String? = null,
|
||||
val folderTextMetadataParsed: Boolean = false,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val lastPageIndex: Int? = null,
|
||||
val readerPosition: ReaderLocator? = null,
|
||||
val readerSettings: ReaderSettings? = null,
|
||||
val readerBookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val readerHighlights: List<UserHighlight> = emptyList()
|
||||
val readerHighlights: List<UserHighlight> = emptyList(),
|
||||
val pdfReaderViewport: SharedPdfReaderViewport? = null
|
||||
)
|
||||
|
||||
data class Shelf(
|
||||
|
|
|
|||
|
|
@ -28,29 +28,18 @@ class LibraryProjector {
|
|||
|
||||
fun withImportedFiles(state: LibraryState, files: List<ImportedFile>): LibraryState {
|
||||
if (files.isEmpty()) return state
|
||||
val now = currentTimestamp()
|
||||
val existingIds = state.books.mapTo(mutableSetOf()) { it.id }
|
||||
val imported = files.mapIndexedNotNull { index, file ->
|
||||
val id = file.path ?: file.name
|
||||
if (!existingIds.add(id)) {
|
||||
null
|
||||
} else {
|
||||
BookItem(
|
||||
id = id,
|
||||
path = file.path,
|
||||
type = file.name.toFileType(),
|
||||
displayName = file.name,
|
||||
timestamp = now + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder ?: file.path?.parentPath(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = files.map { it.toImportedBookFile() },
|
||||
existingBookIds = state.books.mapTo(mutableSetOf()) { it.id },
|
||||
platform = ReaderPlatform.DESKTOP
|
||||
)
|
||||
return state.copy(
|
||||
books = imported + state.books,
|
||||
message = if (imported.isEmpty()) "Those files are already in the desktop library." else "Imported ${imported.size} file(s). Reader support comes later."
|
||||
books = plan.importedBooks + state.books,
|
||||
message = when {
|
||||
plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s). Reader support comes later."
|
||||
plan.unsupportedCount > 0 -> "No supported files were imported."
|
||||
else -> "Those files are already in the desktop library."
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -136,12 +125,6 @@ class LibraryProjector {
|
|||
}
|
||||
}
|
||||
|
||||
private fun String.parentPath(): String? {
|
||||
val normalized = replace('\\', '/')
|
||||
val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
return parent.ifBlank { null }
|
||||
}
|
||||
|
||||
private fun String.folderDisplayName(): String {
|
||||
return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
|
||||
}
|
||||
|
|
@ -153,6 +136,16 @@ data class ImportedFile(
|
|||
val sourceFolder: String? = null
|
||||
)
|
||||
|
||||
private fun ImportedFile.toImportedBookFile(): ImportedBookFile {
|
||||
return ImportedBookFile(
|
||||
name = name,
|
||||
uriString = null,
|
||||
localPath = path,
|
||||
size = size,
|
||||
sourceFolder = sourceFolder
|
||||
)
|
||||
}
|
||||
|
||||
expect fun currentTimestamp(): Long
|
||||
|
||||
fun String.toFileType(): FileType {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class SharedLibraryStateProjector(
|
|||
fun project(input: SharedLibraryProjectionInput): SharedReaderScreenState {
|
||||
val current = input.state
|
||||
val allLibraryBooks = input.booksFromStore
|
||||
val syncedFolders = current.syncedFolders.withSourceFolderFallbacks(allLibraryBooks)
|
||||
val queried = filterBySearch(allLibraryBooks, current.searchQuery)
|
||||
val filtered = applyLibraryFilters(queried, current.libraryFilters)
|
||||
val sortedLibraryBooks = sortBooks(filtered, current.sortOrder)
|
||||
|
|
@ -54,7 +55,7 @@ class SharedLibraryStateProjector(
|
|||
shelfRefs = input.shelfRefs,
|
||||
tags = input.tags,
|
||||
sortOrder = current.sortOrder,
|
||||
syncedFolders = current.syncedFolders
|
||||
syncedFolders = syncedFolders
|
||||
)
|
||||
val validShelfIds = shelfProjection.shelves.mapTo(mutableSetOf()) { it.id }
|
||||
val viewingShelfId = current.viewingShelfId?.takeIf { it in validShelfIds }
|
||||
|
|
@ -89,7 +90,8 @@ class SharedLibraryStateProjector(
|
|||
openTabIds = openTabIds,
|
||||
activeTabBookId = activeTabBookId,
|
||||
booksAvailableForAdding = booksAvailableForAdding,
|
||||
allTags = input.tags
|
||||
allTags = input.tags,
|
||||
syncedFolders = syncedFolders
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +254,21 @@ class SharedLibraryStateProjector(
|
|||
)
|
||||
}
|
||||
|
||||
private fun List<SyncedFolder>.withSourceFolderFallbacks(books: List<BookItem>): List<SyncedFolder> {
|
||||
val knownFolders = mapTo(linkedSetOf()) { it.uriString }
|
||||
val missingFolders = books
|
||||
.mapNotNull { it.sourceFolder?.takeIf(String::isNotBlank) }
|
||||
.filterTo(linkedSetOf()) { knownFolders.add(it) }
|
||||
.map { sourceFolder ->
|
||||
SyncedFolder(
|
||||
uriString = sourceFolder,
|
||||
name = sourceFolder.folderDisplayName(),
|
||||
lastScanTime = 0L
|
||||
)
|
||||
}
|
||||
return if (missingFolders.isEmpty()) this else this + missingFolders
|
||||
}
|
||||
|
||||
private fun String.folderDisplayName(): String {
|
||||
return replace('\\', '/').trimEnd('/').substringAfterLast('/').ifBlank { "Local Folder" }
|
||||
}
|
||||
|
|
@ -303,43 +320,24 @@ fun SharedReaderScreenState.withImportedFiles(
|
|||
now: Long = currentTimestamp()
|
||||
): SharedReaderScreenState {
|
||||
if (files.isEmpty()) return this
|
||||
val existingIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id }
|
||||
val imported = files.mapIndexedNotNull { index, file ->
|
||||
val id = file.localPath ?: file.uriString ?: file.name
|
||||
if (!existingIds.add(id)) {
|
||||
null
|
||||
} else {
|
||||
BookItem(
|
||||
id = id,
|
||||
path = file.localPath ?: file.uriString,
|
||||
type = file.name.toFileType(),
|
||||
displayName = file.name,
|
||||
timestamp = now + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder ?: file.localPath?.parentPath(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = files,
|
||||
existingBookIds = rawLibraryBooks.mapTo(mutableSetOf()) { it.id },
|
||||
platform = ReaderPlatform.DESKTOP,
|
||||
nowMillis = now
|
||||
)
|
||||
return copy(
|
||||
rawLibraryBooks = imported + rawLibraryBooks,
|
||||
rawLibraryBooks = plan.importedBooks + rawLibraryBooks,
|
||||
bannerMessage = BannerMessage(
|
||||
if (imported.isEmpty()) {
|
||||
"Those files are already in the library."
|
||||
} else {
|
||||
"Imported ${imported.size} file(s)."
|
||||
when {
|
||||
plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s)."
|
||||
plan.unsupportedCount > 0 -> "No supported files were imported."
|
||||
else -> "Those files are already in the library."
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.parentPath(): String? {
|
||||
val normalized = replace('\\', '/')
|
||||
val parent = normalized.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
return parent.ifBlank { null }
|
||||
}
|
||||
|
||||
private fun List<BookItem>.withPinnedFirst(pinnedBookIds: Set<String>): List<BookItem> {
|
||||
if (pinnedBookIds.isEmpty()) return this
|
||||
return withIndex()
|
||||
|
|
|
|||
|
|
@ -17,13 +17,34 @@ import kotlinx.serialization.json.longOrNull
|
|||
|
||||
const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
|
||||
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
|
||||
const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_"
|
||||
|
||||
internal expect fun localFolderSyncSha256ShortHex(value: String): String
|
||||
|
||||
fun localFolderSyncSidecarStem(bookId: String): String {
|
||||
return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId)
|
||||
}
|
||||
|
||||
fun localFolderSyncMetadataFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}.json"
|
||||
}
|
||||
|
||||
fun localFolderSyncMetadataTempFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}.tmp"
|
||||
}
|
||||
|
||||
fun localFolderSyncAnnotationFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}$LOCAL_FOLDER_ANNOTATION_SUFFIX.json"
|
||||
}
|
||||
|
||||
fun localFolderSyncAnnotationTempFileName(bookId: String): String {
|
||||
return ".${localFolderSyncSidecarStem(bookId)}$LOCAL_FOLDER_ANNOTATION_SUFFIX.tmp"
|
||||
}
|
||||
|
||||
data class SharedFolderBookMetadata(
|
||||
val bookId: String,
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val displayName: String,
|
||||
val type: String,
|
||||
val lastChapterIndex: Int?,
|
||||
|
|
@ -36,7 +57,15 @@ data class SharedFolderBookMetadata(
|
|||
val locatorBlockIndex: Int?,
|
||||
val locatorCharOffset: Int?,
|
||||
val customName: String?,
|
||||
val highlightsJson: String?
|
||||
val highlightsJson: String?,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val description: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val originalAuthor: String? = null,
|
||||
val originalSeriesName: String? = null,
|
||||
val originalSeriesIndex: Double? = null,
|
||||
val originalDescription: String? = null
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return folderSyncJson.encodeToString(
|
||||
|
|
@ -44,8 +73,6 @@ data class SharedFolderBookMetadata(
|
|||
JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"type" to JsonPrimitive(type),
|
||||
"lastChapterIndex" to JsonPrimitive(lastChapterIndex ?: -1),
|
||||
|
|
@ -76,6 +103,7 @@ data class SharedFolderBookMetadata(
|
|||
.takeIf { it.isNotEmpty() }
|
||||
val parsedType = runCatching { FileType.valueOf(type) }.getOrNull() ?: file.type
|
||||
val metadataTimestamp = lastModifiedTimestamp.takeIf { it > 0L } ?: nowMillis
|
||||
val parsedReaderPosition = readerPositionOrNull()
|
||||
|
||||
return (existing ?: BookItem(
|
||||
id = bookId,
|
||||
|
|
@ -83,9 +111,9 @@ data class SharedFolderBookMetadata(
|
|||
type = parsedType,
|
||||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = metadataTimestamp,
|
||||
title = title ?: displayName.ifBlank { file.name },
|
||||
author = author,
|
||||
title = file.name.substringBeforeLast('.', missingDelimiterValue = file.name),
|
||||
fileSize = file.size,
|
||||
fileContentModifiedTimestamp = file.lastModified,
|
||||
sourceFolder = file.sourceFolder,
|
||||
isRecent = isRecent
|
||||
)).copy(
|
||||
|
|
@ -95,19 +123,38 @@ data class SharedFolderBookMetadata(
|
|||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = if (isRecent || existing == null) metadataTimestamp else existing.timestamp,
|
||||
coverImagePath = existing?.coverImagePath,
|
||||
title = title ?: existing?.title ?: displayName.ifBlank { file.name },
|
||||
author = author ?: existing?.author,
|
||||
title = existing?.title ?: file.name.substringBeforeLast('.', missingDelimiterValue = file.name),
|
||||
author = existing?.author,
|
||||
description = existing?.description,
|
||||
originalTitle = existing?.originalTitle,
|
||||
originalAuthor = existing?.originalAuthor,
|
||||
originalSeriesName = existing?.originalSeriesName,
|
||||
originalSeriesIndex = existing?.originalSeriesIndex,
|
||||
originalDescription = existing?.originalDescription,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent || (existing?.isRecent ?: false),
|
||||
fileSize = file.size.takeIf { it > 0L } ?: existing?.fileSize ?: 0L,
|
||||
fileContentModifiedTimestamp = file.lastModified.takeIf { it > 0L } ?: existing?.fileContentModifiedTimestamp ?: 0L,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false,
|
||||
seriesName = existing?.seriesName,
|
||||
seriesIndex = existing?.seriesIndex,
|
||||
lastPageIndex = lastPage,
|
||||
readerPosition = parsedReaderPosition ?: existing?.readerPosition,
|
||||
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
|
||||
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun readerPositionOrNull(): ReaderLocator? {
|
||||
if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
|
||||
return ReaderLocator.fromLegacy(
|
||||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = lastPage
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReaderBookmarks(bookId: String): List<ReaderBookmark> {
|
||||
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
|
||||
.mapIndexed { index, bookmark ->
|
||||
|
|
@ -135,8 +182,8 @@ data class SharedFolderBookMetadata(
|
|||
val bookId = obj.string("bookId")?.takeIf { it.isNotBlank() } ?: return null
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = bookId,
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = obj.string("displayName") ?: "Unknown",
|
||||
type = obj.string("type") ?: FileType.PDF.name,
|
||||
lastChapterIndex = obj.sentinelInt("lastChapterIndex"),
|
||||
|
|
@ -149,7 +196,15 @@ data class SharedFolderBookMetadata(
|
|||
locatorBlockIndex = obj.sentinelInt("locatorBlockIndex"),
|
||||
locatorCharOffset = obj.sentinelInt("locatorCharOffset"),
|
||||
customName = obj.string("customName"),
|
||||
highlightsJson = obj.string("highlightsJson")
|
||||
highlightsJson = obj.string("highlightsJson"),
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -286,7 +341,10 @@ object LocalFolderSyncEngine {
|
|||
} else {
|
||||
val updatedForFile = existing.withScannedFile(file)
|
||||
val updated = metadata
|
||||
?.takeIf { it.lastModifiedTimestamp > updatedForFile.localFolderModifiedTimestamp() }
|
||||
?.takeIf {
|
||||
it.lastModifiedTimestamp > 0L &&
|
||||
it.lastModifiedTimestamp >= updatedForFile.localFolderModifiedTimestamp()
|
||||
}
|
||||
?.toBookItem(file = file, existing = updatedForFile, nowMillis = nowMillis)
|
||||
?: updatedForFile
|
||||
booksById[stableId] = updated
|
||||
|
|
@ -348,19 +406,33 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
|||
val highlightsJson = readerHighlights
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::highlightsToJson)
|
||||
val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null
|
||||
val isDirty = isRecent || hasProgress || !bookmarksJson.isNullOrBlank() || !highlightsJson.isNullOrBlank()
|
||||
val position = readerPosition
|
||||
val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null || position != null
|
||||
val isDirty = isRecent ||
|
||||
hasProgress ||
|
||||
!bookmarksJson.isNullOrBlank() ||
|
||||
!highlightsJson.isNullOrBlank()
|
||||
if (!isDirty) return null
|
||||
val positionCfi = position?.cfi ?: position?.let { locator ->
|
||||
val chapterIndex = locator.chapterIndex
|
||||
val startOffset = locator.startOffset
|
||||
val endOffset = locator.endOffset ?: startOffset
|
||||
if (chapterIndex != null && startOffset != null && endOffset != null) {
|
||||
"desktop:$chapterIndex:$startOffset:$endOffset"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = title,
|
||||
author = author,
|
||||
title = null,
|
||||
author = null,
|
||||
displayName = displayName,
|
||||
type = type.name,
|
||||
lastChapterIndex = null,
|
||||
lastPage = lastPageIndex,
|
||||
lastPositionCfi = null,
|
||||
lastChapterIndex = position?.chapterIndex,
|
||||
lastPage = position?.pageIndex ?: lastPageIndex,
|
||||
lastPositionCfi = positionCfi,
|
||||
progressPercentage = progressPercentage ?: 0f,
|
||||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = localFolderModifiedTimestamp(),
|
||||
|
|
@ -368,7 +440,15 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
|||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
customName = null,
|
||||
highlightsJson = highlightsJson
|
||||
highlightsJson = highlightsJson,
|
||||
seriesName = null,
|
||||
seriesIndex = null,
|
||||
description = null,
|
||||
originalTitle = null,
|
||||
originalAuthor = null,
|
||||
originalSeriesName = null,
|
||||
originalSeriesIndex = null,
|
||||
originalDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -402,6 +482,7 @@ private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long):
|
|||
timestamp = nowMillis,
|
||||
title = name.substringBeforeLast('.', missingDelimiterValue = name),
|
||||
fileSize = size,
|
||||
fileContentModifiedTimestamp = lastModified,
|
||||
sourceFolder = sourceFolder,
|
||||
isRecent = false
|
||||
)
|
||||
|
|
@ -409,14 +490,28 @@ private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long):
|
|||
|
||||
private fun BookItem.withScannedFile(file: SharedFolderScannedFile): BookItem {
|
||||
val sizeChanged = fileSize > 0L && file.size > 0L && fileSize != file.size
|
||||
val modifiedChanged = file.lastModified > 0L &&
|
||||
file.lastModified != fileContentModifiedTimestamp
|
||||
val contentChanged = sizeChanged || modifiedChanged
|
||||
return copy(
|
||||
path = file.path,
|
||||
type = file.type,
|
||||
displayName = file.name,
|
||||
coverImagePath = if (sizeChanged) null else coverImagePath,
|
||||
coverImagePath = if (contentChanged) null else coverImagePath,
|
||||
title = if (contentChanged) file.name.substringBeforeLast('.', missingDelimiterValue = file.name) else title,
|
||||
author = if (contentChanged) null else author,
|
||||
description = if (contentChanged) null else description,
|
||||
originalTitle = if (contentChanged) null else originalTitle,
|
||||
originalAuthor = if (contentChanged) null else originalAuthor,
|
||||
originalSeriesName = if (contentChanged) null else originalSeriesName,
|
||||
originalSeriesIndex = if (contentChanged) null else originalSeriesIndex,
|
||||
originalDescription = if (contentChanged) null else originalDescription,
|
||||
seriesName = if (contentChanged) null else seriesName,
|
||||
seriesIndex = if (contentChanged) null else seriesIndex,
|
||||
fileSize = file.size.takeIf { it > 0L } ?: fileSize,
|
||||
fileContentModifiedTimestamp = file.lastModified.takeIf { it > 0L } ?: fileContentModifiedTimestamp,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = if (sizeChanged) false else folderTextMetadataParsed
|
||||
folderTextMetadataParsed = if (contentChanged) false else folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,11 +53,8 @@ object EpubAnnotationSerializer {
|
|||
|
||||
fun parseHighlightJsonLenient(rawJson: String?): UserHighlight? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
parseHighlightJson(rawJson)?.let { return it }
|
||||
val unwrapped = runCatching {
|
||||
json.parseToJsonElement(rawJson).jsonPrimitive.content
|
||||
}.getOrNull()
|
||||
return parseHighlightJson(unwrapped)
|
||||
val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return null
|
||||
return element.asHighlightLenientOrNull()
|
||||
}
|
||||
|
||||
fun highlightsToJson(highlights: Collection<UserHighlight>): String {
|
||||
|
|
@ -202,6 +199,22 @@ object EpubAnnotationSerializer {
|
|||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asHighlightLenientOrNull(): UserHighlight? {
|
||||
return when (this) {
|
||||
is JsonObject -> asHighlightOrNull()
|
||||
is JsonArray -> {
|
||||
for (element in this) {
|
||||
element.asHighlightLenientOrNull()?.let { return it }
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> contentOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.startsWith("{") || it.startsWith("[") }
|
||||
?.let { parseHighlightJsonLenient(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserHighlight.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
|
|
|
|||
|
|
@ -319,8 +319,18 @@ object ReaderTtsPlanner {
|
|||
}
|
||||
|
||||
fun chunksFromCurrentLocation(session: ReaderSessionState): List<ReaderTtsChunk> {
|
||||
val pageIndex = session.reader.currentPageIndex
|
||||
return chunksForPages(session.reader.book, session.reader.pages.drop(pageIndex.coerceAtLeast(0)))
|
||||
val anchor = session.navigationLocator
|
||||
val pageIndex = anchor?.pageIndex ?: session.reader.currentPageIndex
|
||||
val pages = session.reader.pages.dropWhile { it.pageIndex < pageIndex.coerceAtLeast(0) }
|
||||
val chunks = chunksForPages(session.reader.book, pages)
|
||||
val chapterIndex = anchor?.chapterIndex
|
||||
val startOffset = anchor?.startOffset
|
||||
if (chapterIndex == null && startOffset == null) return chunks
|
||||
var nextIndex = 0
|
||||
return chunks.mapNotNull { chunk ->
|
||||
chunk.afterLocator(chapterIndex = chapterIndex, startOffset = startOffset)
|
||||
?.copy(index = nextIndex++)
|
||||
}
|
||||
}
|
||||
|
||||
fun chunksForText(
|
||||
|
|
@ -388,6 +398,35 @@ object ReaderTtsPlanner {
|
|||
}
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk.afterLocator(chapterIndex: Int?, startOffset: Int?): ReaderTtsChunk? {
|
||||
if (chapterIndex != null) {
|
||||
if (this.chapterIndex < chapterIndex) return null
|
||||
if (this.chapterIndex > chapterIndex) return this
|
||||
}
|
||||
val anchorOffset = startOffset ?: return this
|
||||
if (endOffset <= anchorOffset) return null
|
||||
if (anchorOffset <= this.startOffset) return this
|
||||
return trimStartTo(anchorOffset)
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk.trimStartTo(sourceOffset: Int): ReaderTtsChunk? {
|
||||
val boundedOffset = sourceOffset.coerceIn(startOffset, endOffset)
|
||||
if (boundedOffset <= startOffset) return this
|
||||
if (boundedOffset >= endOffset) return null
|
||||
val rawDrop = (boundedOffset - startOffset).coerceIn(0, text.length)
|
||||
val remaining = text.drop(rawDrop)
|
||||
val leadingWhitespace = remaining.indexOfFirst { !it.isWhitespace() }
|
||||
if (leadingWhitespace < 0) return null
|
||||
val nextText = remaining.drop(leadingWhitespace)
|
||||
if (nextText.isBlank()) return null
|
||||
val nextStartOffset = (boundedOffset + leadingWhitespace).coerceAtMost(endOffset)
|
||||
return copy(
|
||||
text = nextText,
|
||||
spokenText = nextText,
|
||||
startOffset = nextStartOffset
|
||||
)
|
||||
}
|
||||
|
||||
private fun chunksForSemanticPages(
|
||||
chapter: SharedEpubChapter,
|
||||
pages: List<ReaderPage>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ data class ImportedBookFile(
|
|||
val uriString: String?,
|
||||
val localPath: String?,
|
||||
val size: Long,
|
||||
val sourceFolder: String? = null
|
||||
val sourceFolder: String? = null,
|
||||
val id: String? = null
|
||||
)
|
||||
|
||||
interface BookRepository {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,987 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
enum class SharedSettingsPlatform {
|
||||
ANDROID,
|
||||
DESKTOP
|
||||
}
|
||||
|
||||
enum class SharedSettingsSection(
|
||||
val title: String,
|
||||
val summary: String
|
||||
) {
|
||||
READER(
|
||||
title = "Reader settings",
|
||||
summary = "Global defaults for text, EPUB, PDF, toolbar, and speech"
|
||||
),
|
||||
APP_LIBRARY(
|
||||
title = "App & library",
|
||||
summary = "App preferences, imports, tabs, and local library behavior"
|
||||
),
|
||||
SYNC_ACCOUNTS(
|
||||
title = "Sync & accounts",
|
||||
summary = "Sign-in, cloud sync, and folder backup"
|
||||
),
|
||||
AI_TTS(
|
||||
title = "AI & TTS",
|
||||
summary = "Reader AI, keys, models, voices, and speech preferences"
|
||||
),
|
||||
STORAGE_ADVANCED(
|
||||
title = "Storage & advanced",
|
||||
summary = "Caches and diagnostic tools"
|
||||
),
|
||||
HELP(
|
||||
title = "Help",
|
||||
summary = "Feedback, support, and app information"
|
||||
),
|
||||
EXTRA(
|
||||
title = "Extra",
|
||||
summary = "Overflow options, maintenance, and diagnostics"
|
||||
)
|
||||
}
|
||||
|
||||
enum class SharedSettingsDestination {
|
||||
ROOT,
|
||||
EPUB_TEXT,
|
||||
PDF_COMICS,
|
||||
THEME_APPEARANCE,
|
||||
TTS_AI,
|
||||
LIBRARY_SYNC_STORAGE,
|
||||
SYNC_ACCOUNTS,
|
||||
EXTRA,
|
||||
HELP_ABOUT,
|
||||
EPUB_FORMAT,
|
||||
EPUB_THEME_TEXTURE,
|
||||
EPUB_VISUAL_DEFAULTS,
|
||||
PDF_APPEARANCE_DEFAULTS,
|
||||
PDF_READER_TOOLS,
|
||||
READER_TOOLBAR_DEFAULTS,
|
||||
EPUB_TTS_REPLACEMENTS,
|
||||
GLOBAL_TTS_REPLACEMENTS
|
||||
}
|
||||
|
||||
fun SharedSettingsDestination.parentDestination(): SharedSettingsDestination? {
|
||||
return when (this) {
|
||||
SharedSettingsDestination.ROOT -> null
|
||||
SharedSettingsDestination.EPUB_TEXT,
|
||||
SharedSettingsDestination.PDF_COMICS,
|
||||
SharedSettingsDestination.THEME_APPEARANCE,
|
||||
SharedSettingsDestination.TTS_AI,
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE,
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS,
|
||||
SharedSettingsDestination.EXTRA,
|
||||
SharedSettingsDestination.HELP_ABOUT -> SharedSettingsDestination.ROOT
|
||||
SharedSettingsDestination.EPUB_FORMAT,
|
||||
SharedSettingsDestination.EPUB_THEME_TEXTURE,
|
||||
SharedSettingsDestination.EPUB_VISUAL_DEFAULTS,
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS,
|
||||
SharedSettingsDestination.EPUB_TTS_REPLACEMENTS -> SharedSettingsDestination.EPUB_TEXT
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS,
|
||||
SharedSettingsDestination.PDF_READER_TOOLS -> SharedSettingsDestination.PDF_COMICS
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> SharedSettingsDestination.TTS_AI
|
||||
}
|
||||
}
|
||||
|
||||
enum class SharedSettingsPageKind {
|
||||
ROOT,
|
||||
CATEGORY,
|
||||
DETAIL
|
||||
}
|
||||
|
||||
data class SharedSettingsCategoryModel(
|
||||
val destination: SharedSettingsDestination,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val itemCount: Int
|
||||
) {
|
||||
fun matches(query: String): Boolean {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return true
|
||||
return title.contains(normalized, ignoreCase = true) ||
|
||||
summary.contains(normalized, ignoreCase = true) ||
|
||||
destination.name.contains(normalized, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedSettingsPageModel(
|
||||
val destination: SharedSettingsDestination,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val kind: SharedSettingsPageKind,
|
||||
val parent: SharedSettingsDestination?,
|
||||
val categories: List<SharedSettingsCategoryModel> = emptyList(),
|
||||
val items: List<SharedSettingsItemModel> = emptyList(),
|
||||
val localOverrideNote: SharedSettingsItemModel? = null
|
||||
)
|
||||
|
||||
data class SharedSettingsSearchResult(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val breadcrumb: String,
|
||||
val destination: SharedSettingsDestination? = null,
|
||||
val action: SharedSettingsAction? = null,
|
||||
val kind: SharedSettingsItemKind = SharedSettingsItemKind.NAVIGATION,
|
||||
val enabled: Boolean = true,
|
||||
val checked: Boolean? = null
|
||||
)
|
||||
|
||||
enum class SharedSettingsItemKind {
|
||||
CONTROL,
|
||||
NAVIGATION,
|
||||
TOGGLE,
|
||||
DESTRUCTIVE,
|
||||
INFO
|
||||
}
|
||||
|
||||
enum class SharedSettingsAction {
|
||||
TEXT_READER_DEFAULTS,
|
||||
PDF_READER_DEFAULTS,
|
||||
READER_TOOLBAR,
|
||||
TTS_REPLACEMENTS,
|
||||
LOCAL_OVERRIDE_NOTE,
|
||||
APP_THEME,
|
||||
LANGUAGE,
|
||||
TABS_TOGGLE,
|
||||
RECENT_LIMIT,
|
||||
STRICT_FILE_FILTER,
|
||||
EXTERNAL_FILE_BEHAVIOR,
|
||||
SCREEN_CAPTURE_PROTECTION,
|
||||
CUSTOM_FONTS,
|
||||
SIGN_IN,
|
||||
SIGN_OUT,
|
||||
CLOUD_SYNC,
|
||||
FOLDER_SYNC,
|
||||
DEVICE_MANAGEMENT,
|
||||
AI_SETTINGS,
|
||||
HIDE_READER_AI,
|
||||
TTS_SETTINGS,
|
||||
CLEAR_BOOK_CACHE,
|
||||
CLEAR_REFLOW_CACHE,
|
||||
CLEAR_CLOUD_LOCAL_DATA,
|
||||
TEST_PANEL_DETECTION,
|
||||
TEST_SPEECH_BUBBLE_DETECTION,
|
||||
EXPORT_LOGS,
|
||||
DEBUG_ACTIONS,
|
||||
HELP_FEEDBACK,
|
||||
SUPPORT,
|
||||
ABOUT
|
||||
}
|
||||
|
||||
data class SharedSettingsItemModel(
|
||||
val action: SharedSettingsAction,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val kind: SharedSettingsItemKind = SharedSettingsItemKind.NAVIGATION,
|
||||
val enabled: Boolean = true,
|
||||
val checked: Boolean? = null,
|
||||
val destination: SharedSettingsDestination? = null
|
||||
) {
|
||||
fun matches(query: String): Boolean {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return true
|
||||
return title.contains(normalized, ignoreCase = true) ||
|
||||
summary.contains(normalized, ignoreCase = true) ||
|
||||
action.name.contains(normalized, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedSettingsSectionModel(
|
||||
val section: SharedSettingsSection,
|
||||
val items: List<SharedSettingsItemModel>
|
||||
) {
|
||||
fun matches(query: String): Boolean {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return true
|
||||
return section.title.contains(normalized, ignoreCase = true) ||
|
||||
section.summary.contains(normalized, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedSettingsHubModel(
|
||||
val platform: SharedSettingsPlatform,
|
||||
val sections: List<SharedSettingsSectionModel>
|
||||
) {
|
||||
val rootCategories: List<SharedSettingsCategoryModel>
|
||||
get() = buildRootCategories()
|
||||
|
||||
fun page(destination: SharedSettingsDestination): SharedSettingsPageModel {
|
||||
return when (destination) {
|
||||
SharedSettingsDestination.ROOT -> SharedSettingsPageModel(
|
||||
destination = SharedSettingsDestination.ROOT,
|
||||
title = "Settings",
|
||||
summary = "Global defaults, app preferences, and advanced options",
|
||||
kind = SharedSettingsPageKind.ROOT,
|
||||
parent = null,
|
||||
categories = rootCategories
|
||||
)
|
||||
SharedSettingsDestination.EPUB_TEXT -> categoryPage(
|
||||
destination = destination,
|
||||
title = "EPUB & Text",
|
||||
summary = "Defaults for reflowable reading, layout, EPUB themes, and reader tools",
|
||||
items = epubAndTextItems()
|
||||
)
|
||||
SharedSettingsDestination.PDF_COMICS -> categoryPage(
|
||||
destination = destination,
|
||||
title = "PDF & Comics",
|
||||
summary = "Defaults for fixed-layout reading, PDF themes, and PDF-specific tools",
|
||||
items = pdfAndComicItems()
|
||||
)
|
||||
SharedSettingsDestination.THEME_APPEARANCE -> categoryPage(
|
||||
destination = destination,
|
||||
title = "App Preferences",
|
||||
summary = "App theme and general app behavior",
|
||||
items = themeAndAppearanceItems()
|
||||
)
|
||||
SharedSettingsDestination.TTS_AI -> categoryPage(
|
||||
destination = destination,
|
||||
title = ttsAiTitle(),
|
||||
summary = ttsAiSummary(),
|
||||
items = ttsAndAiItems()
|
||||
)
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE -> categoryPage(
|
||||
destination = destination,
|
||||
title = "Library & Files",
|
||||
summary = "Recent files and local reading fonts",
|
||||
items = libraryAndFileItems()
|
||||
)
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS -> categoryPage(
|
||||
destination = destination,
|
||||
title = "Sync & Accounts",
|
||||
summary = "Sign-in, cloud sync, folder sync, and devices",
|
||||
items = syncAndAccountItems()
|
||||
)
|
||||
SharedSettingsDestination.EXTRA -> categoryPage(
|
||||
destination = destination,
|
||||
title = "Extra",
|
||||
summary = "More-menu options, maintenance actions, diagnostics, and app info",
|
||||
items = extraItems()
|
||||
)
|
||||
SharedSettingsDestination.HELP_ABOUT -> categoryPage(
|
||||
destination = destination,
|
||||
title = "Help & About",
|
||||
summary = "Feedback, support, project information, and licenses",
|
||||
items = helpAndAboutItems()
|
||||
)
|
||||
SharedSettingsDestination.EPUB_FORMAT -> detailPage(
|
||||
destination = destination,
|
||||
title = "Format Defaults",
|
||||
summary = "Font, size, spacing, margins, alignment, and reading mode",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.EPUB_THEME_TEXTURE -> detailPage(
|
||||
destination = destination,
|
||||
title = "EPUB Theme & Texture",
|
||||
summary = "Default EPUB reading theme, paper texture, and texture strength",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.EPUB_VISUAL_DEFAULTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "Visual Defaults",
|
||||
summary = "Page indicators, system UI, images, and chapter-turn behavior",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "PDF Theme Defaults",
|
||||
summary = "Default PDF and comic theme where fixed-layout appearance is supported",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.PDF_READER_TOOLS -> detailPage(
|
||||
destination = destination,
|
||||
title = "PDF Reader Tools",
|
||||
summary = "Auto-scroll, OCR, annotation, and PDF-only tools remain in the PDF reader",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "Reader Toolbar Defaults",
|
||||
summary = "Visible tools, bottom-bar actions, and reader overflow tools",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.EPUB_TTS_REPLACEMENTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "Global TTS Replacements",
|
||||
summary = "Words and phrases replaced only during speech playback",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> detailPage(
|
||||
destination = destination,
|
||||
title = "Global TTS Replacements",
|
||||
summary = "Words and phrases replaced only during speech playback",
|
||||
localOverrideNote = localOverrideItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun searchResults(query: String): List<SharedSettingsSearchResult> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
|
||||
val categoryResults = rootCategories
|
||||
.filter { it.matches(normalized) }
|
||||
.map { category ->
|
||||
SharedSettingsSearchResult(
|
||||
title = category.title,
|
||||
summary = category.summary,
|
||||
breadcrumb = "Settings",
|
||||
destination = category.destination
|
||||
)
|
||||
}
|
||||
|
||||
val itemResults = listOf(
|
||||
SharedSettingsDestination.EPUB_TEXT,
|
||||
SharedSettingsDestination.PDF_COMICS,
|
||||
SharedSettingsDestination.THEME_APPEARANCE,
|
||||
SharedSettingsDestination.TTS_AI,
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE,
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS,
|
||||
SharedSettingsDestination.EXTRA
|
||||
).flatMap { destination ->
|
||||
val page = page(destination)
|
||||
page.items
|
||||
.filter { it.matches(normalized) }
|
||||
.map { item ->
|
||||
SharedSettingsSearchResult(
|
||||
title = item.title,
|
||||
summary = item.summary,
|
||||
breadcrumb = "Settings / ${page.title}",
|
||||
destination = item.destination,
|
||||
action = item.action,
|
||||
kind = item.kind,
|
||||
enabled = item.enabled,
|
||||
checked = item.checked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (categoryResults + itemResults)
|
||||
.distinctBy { result -> result.searchIdentity() }
|
||||
}
|
||||
|
||||
fun filtered(query: String): SharedSettingsHubModel {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return this
|
||||
return copy(
|
||||
sections = sections.mapNotNull { section ->
|
||||
val matchingItems = section.items.filter { it.matches(normalized) }
|
||||
when {
|
||||
matchingItems.isNotEmpty() -> section.copy(items = matchingItems)
|
||||
section.matches(normalized) -> section
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun itemsIn(section: SharedSettingsSection): List<SharedSettingsItemModel> {
|
||||
return sections.firstOrNull { it.section == section }?.items.orEmpty()
|
||||
}
|
||||
|
||||
private fun buildRootCategories(): List<SharedSettingsCategoryModel> {
|
||||
return listOf(
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.EPUB_TEXT,
|
||||
title = "EPUB & Text",
|
||||
summary = "Format, EPUB theme, visual defaults, and reader tools",
|
||||
itemCount = epubAndTextItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.PDF_COMICS,
|
||||
title = "PDF & Comics",
|
||||
summary = "Separate PDF theme and fixed-layout reader defaults",
|
||||
itemCount = pdfAndComicItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.THEME_APPEARANCE,
|
||||
title = "App Preferences",
|
||||
summary = "App theme and general app behavior",
|
||||
itemCount = themeAndAppearanceItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.TTS_AI,
|
||||
title = ttsAiTitle(),
|
||||
summary = ttsAiSummary(),
|
||||
itemCount = ttsAndAiItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.LIBRARY_SYNC_STORAGE,
|
||||
title = "Library & Files",
|
||||
summary = "Recent files and local reading fonts",
|
||||
itemCount = libraryAndFileItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.SYNC_ACCOUNTS,
|
||||
title = "Sync & Accounts",
|
||||
summary = "Sign-in, cloud sync, folder sync, and devices",
|
||||
itemCount = syncAndAccountItems().size
|
||||
),
|
||||
rootCategory(
|
||||
destination = SharedSettingsDestination.EXTRA,
|
||||
title = "Extra",
|
||||
summary = "More-menu options, maintenance, diagnostics, and app info",
|
||||
itemCount = extraItems().size
|
||||
)
|
||||
).filter { it.itemCount > 0 }
|
||||
}
|
||||
|
||||
private fun rootCategory(
|
||||
destination: SharedSettingsDestination,
|
||||
title: String,
|
||||
summary: String,
|
||||
itemCount: Int
|
||||
): SharedSettingsCategoryModel {
|
||||
return SharedSettingsCategoryModel(
|
||||
destination = destination,
|
||||
title = title,
|
||||
summary = summary,
|
||||
itemCount = itemCount
|
||||
)
|
||||
}
|
||||
|
||||
private fun ttsAiTitle(): String {
|
||||
return if (hasAiSettingsItem()) "TTS & AI" else "TTS"
|
||||
}
|
||||
|
||||
private fun ttsAiSummary(): String {
|
||||
return if (hasAiSettingsItem()) {
|
||||
"Global voice, speech replacements, keys, and reader AI"
|
||||
} else {
|
||||
"Global voice, speech behavior, and TTS replacements"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasAiSettingsItem(): Boolean {
|
||||
return baseItem(SharedSettingsAction.AI_SETTINGS) != null
|
||||
}
|
||||
|
||||
private fun categoryPage(
|
||||
destination: SharedSettingsDestination,
|
||||
title: String,
|
||||
summary: String,
|
||||
items: List<SharedSettingsItemModel>
|
||||
): SharedSettingsPageModel {
|
||||
return SharedSettingsPageModel(
|
||||
destination = destination,
|
||||
title = title,
|
||||
summary = summary,
|
||||
kind = SharedSettingsPageKind.CATEGORY,
|
||||
parent = destination.parentDestination(),
|
||||
items = items
|
||||
)
|
||||
}
|
||||
|
||||
private fun detailPage(
|
||||
destination: SharedSettingsDestination,
|
||||
title: String,
|
||||
summary: String,
|
||||
localOverrideNote: SharedSettingsItemModel?
|
||||
): SharedSettingsPageModel {
|
||||
return SharedSettingsPageModel(
|
||||
destination = destination,
|
||||
title = title,
|
||||
summary = summary,
|
||||
kind = SharedSettingsPageKind.DETAIL,
|
||||
parent = destination.parentDestination(),
|
||||
localOverrideNote = localOverrideNote
|
||||
)
|
||||
}
|
||||
|
||||
private fun epubAndTextItems(): List<SharedSettingsItemModel> {
|
||||
return buildList {
|
||||
baseItem(SharedSettingsAction.TEXT_READER_DEFAULTS)?.let { item ->
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.EPUB_FORMAT,
|
||||
title = "Format defaults",
|
||||
summary = "Font, size, line spacing, margins, alignment, and reading mode"
|
||||
)
|
||||
)
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.EPUB_THEME_TEXTURE,
|
||||
title = "Theme and texture",
|
||||
summary = "Reading theme, texture, and page feel for new books"
|
||||
)
|
||||
)
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.EPUB_VISUAL_DEFAULTS,
|
||||
title = "Visual defaults",
|
||||
summary = "System UI, page info, images, and chapter-turn behavior"
|
||||
)
|
||||
)
|
||||
}
|
||||
baseItem(SharedSettingsAction.READER_TOOLBAR)?.let { item ->
|
||||
add(item.destinationRow(SharedSettingsDestination.READER_TOOLBAR_DEFAULTS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pdfAndComicItems(): List<SharedSettingsItemModel> {
|
||||
return buildList {
|
||||
baseItem(SharedSettingsAction.PDF_READER_DEFAULTS)?.let { item ->
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS,
|
||||
title = "PDF theme defaults",
|
||||
summary = "PDF and comic theme defaults, separate from EPUB themes"
|
||||
)
|
||||
)
|
||||
add(
|
||||
item.destinationRow(
|
||||
destination = SharedSettingsDestination.PDF_READER_TOOLS,
|
||||
title = "PDF reader tools",
|
||||
summary = "Auto-scroll, OCR, annotations, and PDF-only tools"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun themeAndAppearanceItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.APP_THEME
|
||||
)
|
||||
}
|
||||
|
||||
private fun ttsAndAiItems(): List<SharedSettingsItemModel> {
|
||||
return buildList {
|
||||
addAll(
|
||||
itemsForActions(
|
||||
SharedSettingsAction.TTS_SETTINGS,
|
||||
SharedSettingsAction.AI_SETTINGS
|
||||
)
|
||||
)
|
||||
baseItem(SharedSettingsAction.TTS_REPLACEMENTS)?.let { item ->
|
||||
add(item.destinationRow(SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun libraryAndFileItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.RECENT_LIMIT,
|
||||
SharedSettingsAction.CUSTOM_FONTS
|
||||
)
|
||||
}
|
||||
|
||||
private fun syncAndAccountItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.SIGN_IN,
|
||||
SharedSettingsAction.SIGN_OUT,
|
||||
SharedSettingsAction.CLOUD_SYNC,
|
||||
SharedSettingsAction.FOLDER_SYNC
|
||||
)
|
||||
}
|
||||
|
||||
private fun extraItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.LANGUAGE,
|
||||
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION,
|
||||
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
|
||||
SharedSettingsAction.STRICT_FILE_FILTER,
|
||||
SharedSettingsAction.TABS_TOGGLE,
|
||||
SharedSettingsAction.HIDE_READER_AI,
|
||||
SharedSettingsAction.CLEAR_BOOK_CACHE,
|
||||
SharedSettingsAction.CLEAR_REFLOW_CACHE,
|
||||
SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA,
|
||||
SharedSettingsAction.TEST_PANEL_DETECTION,
|
||||
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION,
|
||||
SharedSettingsAction.EXPORT_LOGS,
|
||||
SharedSettingsAction.DEVICE_MANAGEMENT,
|
||||
SharedSettingsAction.HELP_FEEDBACK,
|
||||
SharedSettingsAction.SUPPORT,
|
||||
SharedSettingsAction.ABOUT
|
||||
)
|
||||
}
|
||||
|
||||
private fun helpAndAboutItems(): List<SharedSettingsItemModel> {
|
||||
return itemsForActions(
|
||||
SharedSettingsAction.HELP_FEEDBACK,
|
||||
SharedSettingsAction.SUPPORT,
|
||||
SharedSettingsAction.ABOUT
|
||||
)
|
||||
}
|
||||
|
||||
private fun itemsForActions(vararg actions: SharedSettingsAction): List<SharedSettingsItemModel> {
|
||||
return actions.mapNotNull(::baseItem)
|
||||
}
|
||||
|
||||
private fun localOverrideItem(): SharedSettingsItemModel? {
|
||||
return baseItem(SharedSettingsAction.LOCAL_OVERRIDE_NOTE)
|
||||
}
|
||||
|
||||
private fun baseItem(action: SharedSettingsAction): SharedSettingsItemModel? {
|
||||
return sections.asSequence()
|
||||
.flatMap { it.items.asSequence() }
|
||||
.firstOrNull { it.action == action }
|
||||
}
|
||||
|
||||
private fun SharedSettingsItemModel.destinationRow(
|
||||
destination: SharedSettingsDestination,
|
||||
title: String = this.title,
|
||||
summary: String = this.summary
|
||||
): SharedSettingsItemModel {
|
||||
return copy(
|
||||
title = title,
|
||||
summary = summary,
|
||||
kind = SharedSettingsItemKind.NAVIGATION,
|
||||
destination = destination
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSettingsSearchResult.searchIdentity(): String {
|
||||
return when (action) {
|
||||
SharedSettingsAction.TTS_REPLACEMENTS -> SharedSettingsAction.TTS_REPLACEMENTS.name
|
||||
else -> destination?.name ?: action?.name ?: title
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedSettingsHubInput(
|
||||
val platform: SharedSettingsPlatform,
|
||||
val featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard,
|
||||
val isDebugBuild: Boolean = false,
|
||||
val isSignedIn: Boolean = false,
|
||||
val isProUser: Boolean = false,
|
||||
val syncAvailable: Boolean = true,
|
||||
val folderSyncAvailable: Boolean = true,
|
||||
val aiSettingsAvailable: Boolean = true,
|
||||
val ttsSettingsAvailable: Boolean = true,
|
||||
val includePdfReaderDefaults: Boolean = true,
|
||||
val includeReaderToolbar: Boolean = true,
|
||||
val includeLanguage: Boolean = true,
|
||||
val includeScreenCaptureProtection: Boolean = false,
|
||||
val includeExternalFileBehavior: Boolean = true,
|
||||
val includeRecentLimit: Boolean = true,
|
||||
val includeCustomFonts: Boolean = true,
|
||||
val includeStrictFileFilter: Boolean = true,
|
||||
val includeReaderTabs: Boolean = true,
|
||||
val includeHideReaderAi: Boolean = true,
|
||||
val includeCloudLocalDataClear: Boolean = false,
|
||||
val supportProjectAvailable: Boolean = true,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val isSyncEnabled: Boolean = false,
|
||||
val isFolderSyncEnabled: Boolean = false,
|
||||
val useStrictFileFilter: Boolean = false,
|
||||
val isScreenCaptureProtectionEnabled: Boolean = false,
|
||||
val hideReaderAi: Boolean = false
|
||||
)
|
||||
|
||||
fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubModel {
|
||||
val sections = listOf(
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.READER,
|
||||
items = buildList {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TEXT_READER_DEFAULTS,
|
||||
title = "Text and EPUB defaults",
|
||||
summary = "Format, EPUB theme, texture, visual behavior, and text layout",
|
||||
kind = SharedSettingsItemKind.CONTROL
|
||||
)
|
||||
)
|
||||
if (input.includePdfReaderDefaults) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.PDF_READER_DEFAULTS,
|
||||
title = "PDF and comic defaults",
|
||||
summary = "PDF theme, visual defaults, tools, auto-scroll, OCR, and annotation behavior where available",
|
||||
kind = SharedSettingsItemKind.NAVIGATION
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeReaderToolbar) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.READER_TOOLBAR,
|
||||
title = "Reader toolbar and tools",
|
||||
summary = "Choose visible tools, bottom-bar tools, and reader overflow tools",
|
||||
kind = SharedSettingsItemKind.CONTROL
|
||||
)
|
||||
)
|
||||
}
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.LOCAL_OVERRIDE_NOTE,
|
||||
title = "Per-book overrides",
|
||||
summary = "Local overrides are available from the active reader screen and still win for that book.",
|
||||
kind = SharedSettingsItemKind.INFO
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.APP_LIBRARY,
|
||||
items = buildList {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.APP_THEME,
|
||||
title = "App theme",
|
||||
summary = "Theme mode, contrast, reading text dimming, and custom app colors"
|
||||
)
|
||||
)
|
||||
if (input.includeCustomFonts) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CUSTOM_FONTS,
|
||||
title = "Custom fonts",
|
||||
summary = "Import, manage, and reuse local reading fonts"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeRecentLimit) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.RECENT_LIMIT,
|
||||
title = "Recent files limit",
|
||||
summary = "Control how many recent books appear on Home"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.SYNC_ACCOUNTS,
|
||||
items = buildList {
|
||||
if (input.syncAvailable && input.featurePolicy.aiAndCloud) {
|
||||
if (input.isSignedIn) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.SIGN_OUT,
|
||||
title = "Sign out",
|
||||
summary = "Disconnect this device from your account",
|
||||
kind = SharedSettingsItemKind.DESTRUCTIVE
|
||||
)
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.SIGN_IN,
|
||||
title = "Sign in",
|
||||
summary = "Connect sync and account features"
|
||||
)
|
||||
)
|
||||
}
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLOUD_SYNC,
|
||||
title = "Cloud library sync",
|
||||
summary = if (input.isProUser) "Sync library metadata across signed-in devices." else "A Pro account is required for cloud sync.",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
enabled = input.isProUser,
|
||||
checked = input.isSyncEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.folderSyncAvailable) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.FOLDER_SYNC,
|
||||
title = "Folder backup and sync",
|
||||
summary = "Keep selected local folders represented in the library",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.isFolderSyncEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.isDebugBuild && input.featurePolicy.aiAndCloud && input.syncAvailable) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.DEVICE_MANAGEMENT,
|
||||
title = "Device management",
|
||||
summary = "Inspect registered devices for this account"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.AI_TTS,
|
||||
items = buildList {
|
||||
if (input.aiSettingsAvailable && input.featurePolicy.aiAndCloud) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.AI_SETTINGS,
|
||||
title = "AI keys and models",
|
||||
summary = "Configure reader AI and cloud TTS model access"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.ttsSettingsAvailable) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TTS_SETTINGS,
|
||||
title = "TTS voice settings",
|
||||
summary = "Choose cloud or device voices and speech behavior"
|
||||
)
|
||||
)
|
||||
}
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TTS_REPLACEMENTS,
|
||||
title = "Global TTS replacements",
|
||||
summary = "Words and phrases replaced only during speech playback",
|
||||
kind = SharedSettingsItemKind.CONTROL
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.STORAGE_ADVANCED,
|
||||
items = buildList {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLEAR_BOOK_CACHE,
|
||||
title = "Clear book cache",
|
||||
summary = "Remove generated book cache files and recreate them on demand",
|
||||
kind = SharedSettingsItemKind.DESTRUCTIVE
|
||||
)
|
||||
)
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLEAR_REFLOW_CACHE,
|
||||
title = "Clear reflow cache",
|
||||
summary = "Remove generated PDF text-view files",
|
||||
kind = SharedSettingsItemKind.DESTRUCTIVE
|
||||
)
|
||||
)
|
||||
if (input.isDebugBuild) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TEST_PANEL_DETECTION,
|
||||
title = "Test panel detection",
|
||||
summary = "Run the local panel-detection diagnostic"
|
||||
)
|
||||
)
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION,
|
||||
title = "Test speech-bubble detection",
|
||||
summary = "Run the local speech-bubble detection diagnostic"
|
||||
)
|
||||
)
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.EXPORT_LOGS,
|
||||
title = "Export logs",
|
||||
summary = "Export recent diagnostic logs",
|
||||
kind = SharedSettingsItemKind.NAVIGATION
|
||||
)
|
||||
)
|
||||
if (input.includeCloudLocalDataClear && input.featurePolicy.aiAndCloud) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA,
|
||||
title = "Clear cloud and local data",
|
||||
summary = "Delete cloud records and matching local library data",
|
||||
kind = SharedSettingsItemKind.DESTRUCTIVE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.EXTRA,
|
||||
items = buildList {
|
||||
if (input.includeLanguage) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.LANGUAGE,
|
||||
title = "Language",
|
||||
summary = "Choose the app language"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeScreenCaptureProtection) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.SCREEN_CAPTURE_PROTECTION,
|
||||
title = "Screen capture protection",
|
||||
summary = "Block screenshots and screen recording on sensitive reader screens",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.isScreenCaptureProtectionEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeExternalFileBehavior) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
|
||||
title = "External file behavior",
|
||||
summary = "Choose whether external opens are copied into the app library"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeStrictFileFilter) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.STRICT_FILE_FILTER,
|
||||
title = "Strict file filter",
|
||||
summary = "Use only known reader file types in import pickers",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.useStrictFileFilter
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeReaderTabs) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.TABS_TOGGLE,
|
||||
title = "Reader tabs",
|
||||
summary = if (input.isTabsEnabled) "Opening PDFs keeps active tabs." else "PDFs replace the active reader session.",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.isTabsEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeHideReaderAi && input.featurePolicy.aiAndCloud) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.HIDE_READER_AI,
|
||||
title = "Reader AI visibility",
|
||||
summary = if (input.hideReaderAi) "Reader AI tools are hidden." else "Reader AI tools are shown where available.",
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = !input.hideReaderAi
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.HELP,
|
||||
items = buildList {
|
||||
if (input.featurePolicy.projectLinks) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.HELP_FEEDBACK,
|
||||
title = "Help and feedback",
|
||||
summary = "Send feedback or report an issue"
|
||||
)
|
||||
)
|
||||
if (input.supportProjectAvailable) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.SUPPORT,
|
||||
title = "Support project",
|
||||
summary = "Open support options for the project"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.ABOUT,
|
||||
title = "About",
|
||||
summary = "Version, source, licenses, and project information"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
).filter { it.items.isNotEmpty() }
|
||||
|
||||
return SharedSettingsHubModel(
|
||||
platform = input.platform,
|
||||
sections = sections
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
data class SharedFeaturePolicy(
|
||||
val networkAccess: Boolean = true,
|
||||
val opdsCatalogs: Boolean = networkAccess,
|
||||
val aiAndCloud: Boolean = networkAccess,
|
||||
val externalLookup: Boolean = networkAccess,
|
||||
val projectLinks: Boolean = networkAccess,
|
||||
val googleFontsDownload: Boolean = networkAccess
|
||||
) {
|
||||
companion object {
|
||||
val Standard = SharedFeaturePolicy()
|
||||
val OssOffline = SharedFeaturePolicy(
|
||||
networkAccess = false,
|
||||
opdsCatalogs = false,
|
||||
aiAndCloud = false,
|
||||
externalLookup = false,
|
||||
projectLinks = false,
|
||||
googleFontsDownload = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,13 +9,17 @@ import kotlinx.serialization.json.JsonPrimitive
|
|||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
|
|
@ -28,7 +32,7 @@ data class SharedLibrarySnapshot(
|
|||
val customFonts: List<CustomFontItem> = emptyList(),
|
||||
val syncedFolders: List<SyncedFolder> = emptyList(),
|
||||
val recentFilesLimit: Int = 12,
|
||||
val isTabsEnabled: Boolean = false,
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
val pinnedHomeBookIds: Set<String> = emptySet(),
|
||||
|
|
@ -40,13 +44,16 @@ data class SharedLibrarySnapshot(
|
|||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: Color? = null,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
||||
object SharedLibrarySnapshotJson {
|
||||
private const val SCHEMA_VERSION = 10
|
||||
private const val SCHEMA_VERSION = 19
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
|
|
@ -60,6 +67,10 @@ object SharedLibrarySnapshotJson {
|
|||
|
||||
val schemaVersion = root.int("schemaVersion", 1)
|
||||
val openTabIds = root.stringArray("openTabIds")
|
||||
val readerDefaultSettings = root["readerDefaultSettings"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderSettingsOrNull()
|
||||
?: ReaderSettings()
|
||||
return SharedLibrarySnapshot(
|
||||
books = root.array("books")
|
||||
.mapNotNull { it.asBookItemOrNull() }
|
||||
|
|
@ -70,7 +81,7 @@ object SharedLibrarySnapshotJson {
|
|||
customFonts = root.array("customFonts").mapNotNull { it.asCustomFontItemOrNull() },
|
||||
syncedFolders = root.array("syncedFolders").mapNotNull { it.asSyncedFolderOrNull() },
|
||||
recentFilesLimit = root.int("recentFilesLimit", 12),
|
||||
isTabsEnabled = root.boolean("isTabsEnabled", false),
|
||||
isTabsEnabled = root.boolean("isTabsEnabled", true),
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = root.string("activeTabBookId"),
|
||||
pinnedHomeBookIds = root.stringArray("pinnedHomeBookIds").toSet(),
|
||||
|
|
@ -90,6 +101,11 @@ object SharedLibrarySnapshotJson {
|
|||
?: 1.0f,
|
||||
appSeedColor = root.int("appSeedColor")?.let { Color(it) },
|
||||
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
|
||||
readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion),
|
||||
pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderSettingsOrNull()
|
||||
?: ReaderSettings(themeId = "no_theme"),
|
||||
readerToolbarPreferences = root["readerToolbarPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderToolbarPreferencesOrNull()
|
||||
|
|
@ -98,6 +114,10 @@ object SharedLibrarySnapshotJson {
|
|||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderHighlightPaletteOrNull()
|
||||
?: ReaderHighlightPalette(),
|
||||
pdfHighlighterPalette = root["pdfHighlighterPalette"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asSharedPdfHighlighterPaletteOrNull()
|
||||
?: SharedPdfHighlighterPalette(),
|
||||
readerTtsReplacementPreferences = root["readerTtsReplacementPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.let { ReaderTtsReplacementPreferencesJson.fromJsonElement(it) }
|
||||
|
|
@ -128,8 +148,11 @@ object SharedLibrarySnapshotJson {
|
|||
"appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark),
|
||||
"appSeedColor" to snapshot.appSeedColor.asJson(),
|
||||
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
|
||||
"readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(),
|
||||
"pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(),
|
||||
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
|
||||
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
|
||||
"pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(),
|
||||
"readerTtsReplacementPreferences" to ReaderTtsReplacementPreferencesJson.toJsonElement(
|
||||
snapshot.readerTtsReplacementPreferences,
|
||||
)
|
||||
|
|
@ -149,6 +172,12 @@ private fun JsonObject.stringArray(name: String): List<String> {
|
|||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.intArray(name: String): List<Int> {
|
||||
return array(name).mapNotNull { element ->
|
||||
runCatching { element.jsonPrimitive.intOrNull }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
|
@ -196,10 +225,18 @@ private fun List<BookItem>.migrateLegacyRecentState(schemaVersion: Int, openTabI
|
|||
private fun BookItem.hasReaderFootprint(openedBookIds: Set<String>): Boolean {
|
||||
return id in openedBookIds ||
|
||||
lastPageIndex != null ||
|
||||
readerPosition != null ||
|
||||
(progressPercentage ?: 0f) > 0f ||
|
||||
readerSettings != null ||
|
||||
readerBookmarks.isNotEmpty() ||
|
||||
readerHighlights.isNotEmpty()
|
||||
readerHighlights.isNotEmpty() ||
|
||||
pdfReaderViewport != null
|
||||
}
|
||||
|
||||
private fun ReaderSettings.migrateLegacyDefaultReadingMode(schemaVersion: Int): ReaderSettings {
|
||||
if (schemaVersion >= 17 || readingMode != ReaderReadingMode.PAGINATED) return this
|
||||
val oldDefaultSettings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED)
|
||||
return if (this == oldDefaultSettings) copy(readingMode = ReaderReadingMode.VERTICAL) else this
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
||||
|
|
@ -216,18 +253,27 @@ private fun JsonElement.asBookItemOrNull(): BookItem? {
|
|||
coverImagePath = obj.string("coverImagePath"),
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
description = obj.string("description"),
|
||||
originalTitle = obj.string("originalTitle"),
|
||||
originalAuthor = obj.string("originalAuthor"),
|
||||
originalSeriesName = obj.string("originalSeriesName"),
|
||||
originalSeriesIndex = obj.double("originalSeriesIndex"),
|
||||
originalDescription = obj.string("originalDescription"),
|
||||
progressPercentage = obj.float("progressPercentage"),
|
||||
isRecent = obj.boolean("isRecent", true),
|
||||
fileSize = obj.long("fileSize"),
|
||||
fileContentModifiedTimestamp = obj.long("fileContentModifiedTimestamp"),
|
||||
sourceFolder = obj.string("sourceFolder"),
|
||||
folderTextMetadataParsed = obj.boolean("folderTextMetadataParsed", false),
|
||||
seriesName = obj.string("seriesName"),
|
||||
seriesIndex = obj.double("seriesIndex"),
|
||||
tags = obj.array("tags").mapNotNull { it.asTagOrNull() },
|
||||
lastPageIndex = obj.int("lastPageIndex"),
|
||||
readerPosition = obj["readerPosition"]?.takeUnless { it is JsonNull }?.asReaderLocatorOrNull(),
|
||||
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
|
||||
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() }
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() },
|
||||
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -282,8 +328,9 @@ private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
|
|||
lastScanTime = obj.long("lastScanTime"),
|
||||
allowedFileTypes = obj.stringArray("allowedFileTypes")
|
||||
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
|
||||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.toSet()
|
||||
.ifEmpty { FileType.entries.toSet() }
|
||||
.ifEmpty { SharedFileCapabilities.knownFileTypes }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -307,18 +354,27 @@ private fun BookItem.toJsonObject(): JsonObject {
|
|||
"coverImagePath" to coverImagePath.asJson(),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"description" to description.asJson(),
|
||||
"originalTitle" to originalTitle.asJson(),
|
||||
"originalAuthor" to originalAuthor.asJson(),
|
||||
"originalSeriesName" to originalSeriesName.asJson(),
|
||||
"originalSeriesIndex" to originalSeriesIndex.asJson(),
|
||||
"originalDescription" to originalDescription.asJson(),
|
||||
"progressPercentage" to progressPercentage.asJson(),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"fileSize" to JsonPrimitive(fileSize),
|
||||
"fileContentModifiedTimestamp" to JsonPrimitive(fileContentModifiedTimestamp),
|
||||
"sourceFolder" to sourceFolder.asJson(),
|
||||
"folderTextMetadataParsed" to JsonPrimitive(folderTextMetadataParsed),
|
||||
"seriesName" to seriesName.asJson(),
|
||||
"seriesIndex" to seriesIndex.asJson(),
|
||||
"tags" to JsonArray(tags.map { it.toJsonObject() }),
|
||||
"lastPageIndex" to lastPageIndex.asJson(),
|
||||
"readerPosition" to readerPosition.asJson(),
|
||||
"readerSettings" to readerSettings.asJson(),
|
||||
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() })
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }),
|
||||
"pdfReaderViewport" to pdfReaderViewport.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -374,7 +430,11 @@ private fun SyncedFolder.toJsonObject(): JsonObject {
|
|||
"uriString" to JsonPrimitive(uriString),
|
||||
"name" to JsonPrimitive(name),
|
||||
"lastScanTime" to JsonPrimitive(lastScanTime),
|
||||
"allowedFileTypes" to allowedFileTypes.map { it.name }.sorted().asJsonArray()
|
||||
"allowedFileTypes" to allowedFileTypes
|
||||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
.asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -400,6 +460,10 @@ private fun List<String>.asJsonArray(): JsonArray {
|
|||
return JsonArray(map { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
private fun List<Int>.asIntJsonArray(): JsonArray {
|
||||
return JsonArray(map { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val defaults = ReaderSettings()
|
||||
|
|
@ -435,6 +499,17 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
|||
pageInfoPosition = obj.string("pageInfoPosition")
|
||||
?.let { runCatching { PageInfoPosition.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageInfoPosition,
|
||||
pageSpreadMode = obj.string("pageSpreadMode")
|
||||
?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageSpreadMode,
|
||||
pdfVerticalPageGapVisible = obj.boolean(
|
||||
"pdfVerticalPageGapVisible",
|
||||
defaults.pdfVerticalPageGapVisible
|
||||
),
|
||||
pdfPageNumberOverlayVisible = obj.boolean(
|
||||
"pdfPageNumberOverlayVisible",
|
||||
defaults.pdfPageNumberOverlayVisible
|
||||
),
|
||||
seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation),
|
||||
chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier
|
||||
)
|
||||
|
|
@ -462,6 +537,29 @@ private fun JsonElement.asReaderHighlightPaletteOrNull(): ReaderHighlightPalette
|
|||
return ReaderHighlightPalette(colors = colors).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asSharedPdfHighlighterPaletteOrNull(): SharedPdfHighlighterPalette? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return SharedPdfHighlighterPalette(colors = obj.intArray("colorsArgb")).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asSharedPdfReaderViewportOrNull(): SharedPdfReaderViewport? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val defaults = SharedPdfReaderViewport()
|
||||
return SharedPdfReaderViewport(
|
||||
pageIndex = obj.int("pageIndex") ?: defaults.pageIndex,
|
||||
displayMode = obj.string("displayMode")
|
||||
?.let { runCatching { PdfDisplayMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.displayMode,
|
||||
zoom = obj.float("zoom") ?: defaults.zoom,
|
||||
horizontalScrollOffset = obj.int("horizontalScrollOffset") ?: defaults.horizontalScrollOffset,
|
||||
paginatedVerticalScrollOffset = obj.int("paginatedVerticalScrollOffset")
|
||||
?: defaults.paginatedVerticalScrollOffset,
|
||||
verticalFirstPageIndex = obj.int("verticalFirstPageIndex") ?: defaults.verticalFirstPageIndex,
|
||||
verticalFirstPageScrollOffset = obj.int("verticalFirstPageScrollOffset")
|
||||
?: defaults.verticalFirstPageScrollOffset
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderBookmarkOrNull(): ReaderBookmark? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val pageIndex = obj.int("pageIndex") ?: return null
|
||||
|
|
@ -551,6 +649,9 @@ private fun ReaderSettings?.asJson(): JsonElement {
|
|||
"systemUiMode" to JsonPrimitive(settings.systemUiMode.name),
|
||||
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
|
||||
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
|
||||
"pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name),
|
||||
"pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible),
|
||||
"pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible),
|
||||
"seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation),
|
||||
"chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier)
|
||||
)
|
||||
|
|
@ -576,6 +677,29 @@ private fun ReaderHighlightPalette.toJsonObject(): JsonObject {
|
|||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfHighlighterPalette.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"colorsArgb" to sanitized().colors.asIntJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderViewport?.asJson(): JsonElement {
|
||||
val viewport = this ?: return JsonNull
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"pageIndex" to JsonPrimitive(viewport.pageIndex),
|
||||
"displayMode" to JsonPrimitive(viewport.displayMode.name),
|
||||
"zoom" to JsonPrimitive(viewport.zoom),
|
||||
"horizontalScrollOffset" to JsonPrimitive(viewport.horizontalScrollOffset),
|
||||
"paginatedVerticalScrollOffset" to JsonPrimitive(viewport.paginatedVerticalScrollOffset),
|
||||
"verticalFirstPageIndex" to JsonPrimitive(viewport.verticalFirstPageIndex),
|
||||
"verticalFirstPageScrollOffset" to JsonPrimitive(viewport.verticalFirstPageScrollOffset)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
|
|
@ -616,3 +740,7 @@ private fun ReaderLocator.toJsonObject(): JsonObject {
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator?.asJson(): JsonElement {
|
||||
return this?.toJsonObject() ?: JsonNull
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ fun LibraryState.reduce(action: LibraryAction): LibraryState {
|
|||
}
|
||||
copy(selectedBookIds = selected)
|
||||
}
|
||||
is LibraryAction.BookSelectionReplaced -> copy(selectedBookIds = action.bookIds)
|
||||
LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet())
|
||||
is LibraryAction.ShelfSelectionToggled -> this
|
||||
LibraryAction.ShelfSelectionCleared -> this
|
||||
|
|
@ -37,6 +38,7 @@ fun SharedReaderScreenState.reduce(action: LibraryAction): SharedReaderScreenSta
|
|||
}
|
||||
copy(selectedBookIds = selected)
|
||||
}
|
||||
is LibraryAction.BookSelectionReplaced -> copy(selectedBookIds = action.bookIds)
|
||||
LibraryAction.SelectionCleared -> copy(selectedBookIds = emptySet())
|
||||
is LibraryAction.ShelfSelectionToggled -> {
|
||||
val selected = if (action.shelfId in selectedShelfIds) {
|
||||
|
|
@ -52,6 +54,18 @@ fun SharedReaderScreenState.reduce(action: LibraryAction): SharedReaderScreenSta
|
|||
}
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.replaceBookSelectionWithVisibleBooks(
|
||||
visibleBooks: Collection<BookItem>
|
||||
): SharedReaderScreenState {
|
||||
val visibleIds = visibleBooks.mapTo(linkedSetOf()) { it.id }
|
||||
val action = if (visibleIds.isNotEmpty() && selectedBookIds.containsAll(visibleIds)) {
|
||||
LibraryAction.SelectionCleared
|
||||
} else {
|
||||
LibraryAction.BookSelectionReplaced(visibleIds)
|
||||
}
|
||||
return reduce(action)
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
||||
return when (action) {
|
||||
is AppAction.BannerShown -> copy(bannerMessage = action.message)
|
||||
|
|
@ -115,6 +129,12 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
pinnedLibraryBookIds + action.bookId
|
||||
}
|
||||
)
|
||||
is AppAction.ReaderDefaultSettingsChanged -> copy(
|
||||
readerDefaultSettings = action.settings
|
||||
)
|
||||
is AppAction.PdfReaderDefaultSettingsChanged -> copy(
|
||||
pdfReaderDefaultSettings = action.settings
|
||||
)
|
||||
is AppAction.ReaderToolbarPreferencesChanged -> copy(
|
||||
readerToolbarPreferences = action.preferences.sanitized()
|
||||
)
|
||||
|
|
@ -130,6 +150,9 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
is AppAction.ReaderHighlightPaletteChanged -> copy(
|
||||
readerHighlightPalette = action.palette.sanitized()
|
||||
)
|
||||
is AppAction.PdfHighlighterPaletteChanged -> copy(
|
||||
pdfHighlighterPalette = action.palette.sanitized()
|
||||
)
|
||||
is AppAction.ReaderTtsReplacementPreferencesChanged -> copy(
|
||||
readerTtsReplacementPreferences = action.preferences
|
||||
)
|
||||
|
|
@ -145,8 +168,18 @@ fun ReaderSessionState.reduce(action: ReaderAction, readerEngine: ReaderEngine):
|
|||
is ReaderAction.GoToProgress -> readerEngine.goToProgress(this, action.progress)
|
||||
is ReaderAction.GoToChapter -> readerEngine.goToChapter(this, action.chapterIndex)
|
||||
is ReaderAction.GoToLocator -> readerEngine.goToLocator(this, action.locator)
|
||||
is ReaderAction.JumpToPage -> readerEngine.jumpToPage(this, action.pageIndex)
|
||||
is ReaderAction.JumpToPageNumber -> readerEngine.jumpToPageNumber(this, action.pageNumber)
|
||||
is ReaderAction.JumpToChapter -> readerEngine.jumpToChapter(this, action.chapterIndex)
|
||||
is ReaderAction.JumpToLocator -> readerEngine.jumpToLocator(this, action.locator)
|
||||
is ReaderAction.VisiblePageChanged -> readerEngine.syncVisiblePage(this, action.pageIndex, action.locator)
|
||||
is ReaderAction.GoToSearchResult -> readerEngine.goToSearchResult(this, action.resultIndex)
|
||||
is ReaderAction.JumpToSearchResult -> readerEngine.jumpToSearchResult(this, action.resultIndex)
|
||||
ReaderAction.JumpToNextSearchResult -> readerEngine.jumpToNextSearchResult(this)
|
||||
ReaderAction.JumpToPreviousSearchResult -> readerEngine.jumpToPreviousSearchResult(this)
|
||||
ReaderAction.JumpBack -> readerEngine.jumpBack(this)
|
||||
ReaderAction.JumpForward -> readerEngine.jumpForward(this)
|
||||
ReaderAction.JumpHistoryCleared -> readerEngine.clearJumpHistory(this)
|
||||
is ReaderAction.SearchChanged -> readerEngine.search(this, action.query)
|
||||
ReaderAction.SearchOpened -> readerEngine.openSearch(this)
|
||||
ReaderAction.SearchClosed -> readerEngine.closeSearch(this)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ data class OpdsAcquisition(
|
|||
get() = when {
|
||||
mimeType.contains("epub", ignoreCase = true) -> "EPUB"
|
||||
mimeType.contains("pdf", ignoreCase = true) -> "PDF"
|
||||
mimeType.contains("presentationml.presentation", ignoreCase = true) ||
|
||||
mimeType.contains("pptx", ignoreCase = true) -> "PPTX"
|
||||
mimeType.contains("markdown", ignoreCase = true) ||
|
||||
mimeType.contains("text/x-markdown", ignoreCase = true) -> "MD"
|
||||
mimeType.contains("html", ignoreCase = true) ||
|
||||
|
|
@ -58,6 +60,7 @@ data class OpdsAcquisition(
|
|||
get() = when (formatName) {
|
||||
"EPUB" -> 5
|
||||
"PDF" -> 4
|
||||
"PPTX" -> 4
|
||||
"MOBI" -> 3
|
||||
"FB2", "MD", "HTML" -> 2
|
||||
"CBZ", "CBR", "CB7" -> 1
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ object SharedOpdsDownloadNamer {
|
|||
return when (acquisition.formatName) {
|
||||
"EPUB" -> ".epub"
|
||||
"PDF" -> ".pdf"
|
||||
"PPTX" -> ".pptx"
|
||||
"MOBI" -> ".mobi"
|
||||
"FB2" -> ".fb2"
|
||||
"CBZ" -> ".cbz"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ enum class PdfAnnotationKind {
|
|||
}
|
||||
|
||||
enum class PdfInkTool {
|
||||
NONE,
|
||||
PEN,
|
||||
HIGHLIGHTER,
|
||||
HIGHLIGHTER_ROUND,
|
||||
|
|
@ -52,6 +53,7 @@ data class SharedPdfAnnotation(
|
|||
val backgroundArgb: Int = 0x00FFFFFF,
|
||||
val strokeWidth: Float = 2f,
|
||||
val fontSize: Float = 16f,
|
||||
val pageRelativeFontSize: Float? = null,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
|
|
@ -159,6 +161,7 @@ object SharedPdfAnnotationDefaults {
|
|||
|
||||
fun configFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return when (tool) {
|
||||
PdfInkTool.NONE -> PdfToolConfig(0x00000000, 0.008f)
|
||||
PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f)
|
||||
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f)
|
||||
PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f)
|
||||
|
|
@ -170,6 +173,76 @@ object SharedPdfAnnotationDefaults {
|
|||
}
|
||||
}
|
||||
|
||||
data class SharedPdfHighlighterPalette(
|
||||
val colors: List<Int> = defaultColors
|
||||
) {
|
||||
fun sanitized(): SharedPdfHighlighterPalette {
|
||||
val normalized = colors
|
||||
.filter { it != 0 }
|
||||
.map { it.withPdfHighlighterAlpha() }
|
||||
.take(MaxColors)
|
||||
val filled = if (normalized.isEmpty()) {
|
||||
defaultColors
|
||||
} else {
|
||||
normalized + defaultColors.drop(normalized.size)
|
||||
}
|
||||
return copy(colors = filled.take(MaxColors))
|
||||
}
|
||||
|
||||
fun withColorAt(slotIndex: Int, colorArgb: Int): SharedPdfHighlighterPalette {
|
||||
val nextColors = sanitized().colors.toMutableList()
|
||||
if (slotIndex !in nextColors.indices) return sanitized()
|
||||
nextColors[slotIndex] = colorArgb.withPdfHighlighterAlpha()
|
||||
return copy(colors = nextColors).sanitized()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DefaultAlpha: Int = 0x8C
|
||||
const val MaxColors: Int = 5
|
||||
val defaultColors: List<Int>
|
||||
get() = SharedPdfAnnotationDefaults.highlighterPalette.map { it.withPdfHighlighterAlpha() }
|
||||
}
|
||||
}
|
||||
|
||||
object SharedPdfAndroidHighlightColors {
|
||||
const val StoredAlpha: Int = 0x8C
|
||||
const val RenderAlpha: Float = 0.4f
|
||||
|
||||
val colorsByName: Map<String, Int> = mapOf(
|
||||
"YELLOW" to 0xFFFBC02D.toInt(),
|
||||
"GREEN" to 0xFF388E3C.toInt(),
|
||||
"BLUE" to 0xFF1976D2.toInt(),
|
||||
"RED" to 0xFFD32F2F.toInt()
|
||||
)
|
||||
|
||||
val palette: List<Int>
|
||||
get() = colorsByName.keys.map(::argbForName)
|
||||
|
||||
fun argbForName(name: String): Int {
|
||||
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("YELLOW")
|
||||
return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
fun nearestName(argb: Int): String {
|
||||
val rgb = argb and 0x00FFFFFF
|
||||
return colorsByName.minByOrNull { (_, color) ->
|
||||
val candidate = color and 0x00FFFFFF
|
||||
val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF)
|
||||
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
|
||||
val db = (rgb and 0xFF) - (candidate and 0xFF)
|
||||
dr * dr + dg * dg + db * db
|
||||
}?.key ?: "YELLOW"
|
||||
}
|
||||
|
||||
fun nearestArgb(argb: Int): Int {
|
||||
return argbForName(nearestName(argb))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.withPdfHighlighterAlpha(): Int {
|
||||
return (SharedPdfHighlighterPalette.DefaultAlpha shl 24) or (this and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfAnnotationStore(
|
||||
val version: Int = 1,
|
||||
|
|
|
|||
|
|
@ -144,17 +144,45 @@ data class SharedPdfJumpHistory(
|
|||
}
|
||||
}
|
||||
|
||||
data class SharedPdfReaderViewport(
|
||||
val pageIndex: Int = 0,
|
||||
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
|
||||
val zoom: Float = PdfZoomSpec().default,
|
||||
val horizontalScrollOffset: Int = 0,
|
||||
val paginatedVerticalScrollOffset: Int = 0,
|
||||
val verticalFirstPageIndex: Int = pageIndex,
|
||||
val verticalFirstPageScrollOffset: Int = 0
|
||||
) {
|
||||
fun sanitized(
|
||||
pageCount: Int,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderViewport {
|
||||
val lastPageIndex = (pageCount.coerceAtLeast(0) - 1).coerceAtLeast(0)
|
||||
val safeZoom = if (zoom.isFinite() && zoom > 0f) zoom else zoomSpec.default
|
||||
return copy(
|
||||
pageIndex = pageIndex.coerceIn(0, lastPageIndex),
|
||||
zoom = zoomSpec.clamp(safeZoom),
|
||||
horizontalScrollOffset = horizontalScrollOffset.coerceAtLeast(0),
|
||||
paginatedVerticalScrollOffset = paginatedVerticalScrollOffset.coerceAtLeast(0),
|
||||
verticalFirstPageIndex = verticalFirstPageIndex.coerceIn(0, lastPageIndex),
|
||||
verticalFirstPageScrollOffset = verticalFirstPageScrollOffset.coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfReaderState(
|
||||
val pageIndex: Int = 0,
|
||||
val pageCount: Int = 0,
|
||||
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
|
||||
val zoom: Float = PdfZoomSpec().default,
|
||||
val isSearchActive: Boolean = false,
|
||||
val showSearchResultsPanel: Boolean = true,
|
||||
val searchQuery: String = "",
|
||||
val activeSearchResultIndex: Int = -1,
|
||||
val searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
|
||||
val selectedTool: PdfInkTool = PdfInkTool.PEN,
|
||||
val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
|
||||
val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
val selectedTool: PdfInkTool = PdfInkTool.NONE,
|
||||
val selectedColorArgb: Int = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE).colorArgb,
|
||||
val strokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE).strokeWidth,
|
||||
val isTextSelectionMode: Boolean = false,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList(),
|
||||
val selectedAnnotationId: String? = null,
|
||||
|
|
@ -208,6 +236,9 @@ sealed interface SharedPdfReaderAction {
|
|||
data class ZoomChanged(val zoom: Float) : SharedPdfReaderAction
|
||||
data class ZoomBy(val delta: Float) : SharedPdfReaderAction
|
||||
data class SearchChanged(val query: String) : SharedPdfReaderAction
|
||||
data object SearchOpened : SharedPdfReaderAction
|
||||
data object SearchClosed : SharedPdfReaderAction
|
||||
data object SearchResultsPanelToggled : SharedPdfReaderAction
|
||||
data class SearchHighlightModeChanged(val mode: SearchHighlightMode) : SharedPdfReaderAction
|
||||
data object SearchHighlightModeToggled : SharedPdfReaderAction
|
||||
data class GoToSearchResult(
|
||||
|
|
@ -257,10 +288,26 @@ fun SharedPdfReaderState.reduce(
|
|||
)
|
||||
is SharedPdfReaderAction.ZoomChanged -> copy(zoom = zoomSpec.clamp(action.zoom))
|
||||
is SharedPdfReaderAction.ZoomBy -> copy(zoom = zoomSpec.clamp(zoom + action.delta))
|
||||
is SharedPdfReaderAction.SearchChanged -> copy(
|
||||
searchQuery = action.query,
|
||||
is SharedPdfReaderAction.SearchChanged -> {
|
||||
val normalized = action.query.trim()
|
||||
copy(
|
||||
isSearchActive = isSearchActive || normalized.isNotBlank(),
|
||||
showSearchResultsPanel = showSearchResultsPanel || normalized.isNotBlank(),
|
||||
searchQuery = action.query,
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
}
|
||||
SharedPdfReaderAction.SearchOpened -> copy(
|
||||
isSearchActive = true,
|
||||
showSearchResultsPanel = true
|
||||
)
|
||||
SharedPdfReaderAction.SearchClosed -> copy(
|
||||
isSearchActive = false,
|
||||
showSearchResultsPanel = true,
|
||||
searchQuery = "",
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
SharedPdfReaderAction.SearchResultsPanelToggled -> copy(showSearchResultsPanel = !showSearchResultsPanel)
|
||||
is SharedPdfReaderAction.SearchHighlightModeChanged -> copy(searchHighlightMode = action.mode)
|
||||
SharedPdfReaderAction.SearchHighlightModeToggled -> copy(
|
||||
searchHighlightMode = when (searchHighlightMode) {
|
||||
|
|
@ -284,12 +331,25 @@ fun SharedPdfReaderState.reduce(
|
|||
copy(
|
||||
selectedTool = action.tool,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth
|
||||
strokeWidth = config.strokeWidth,
|
||||
isTextSelectionMode = false
|
||||
)
|
||||
}
|
||||
is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb)
|
||||
is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f))
|
||||
is SharedPdfReaderAction.TextSelectionModeChanged -> copy(isTextSelectionMode = action.enabled)
|
||||
is SharedPdfReaderAction.TextSelectionModeChanged -> {
|
||||
if (action.enabled) {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE)
|
||||
copy(
|
||||
isTextSelectionMode = true,
|
||||
selectedTool = PdfInkTool.NONE,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth
|
||||
)
|
||||
} else {
|
||||
copy(isTextSelectionMode = false)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.BookmarksLoaded -> copy(bookmarks = action.bookmarks.normalizedBookmarks(lastPageIndex))
|
||||
is SharedPdfReaderAction.BookmarkToggled -> {
|
||||
val page = action.pageIndex.coerceIn(0, lastPageIndex)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.floor
|
||||
|
||||
data class PdfVisiblePageLayout(
|
||||
val pageIndex: Int,
|
||||
val top: Float,
|
||||
|
|
@ -28,3 +32,73 @@ fun mostVisiblePdfPageIndex(
|
|||
?.pageIndex
|
||||
?: fallbackPageIndex
|
||||
}
|
||||
|
||||
fun pdfVerticalPageGapDp(
|
||||
isPageGapVisible: Boolean,
|
||||
defaultGap: Dp
|
||||
): Dp = if (isPageGapVisible) defaultGap else 0.dp
|
||||
|
||||
data class PdfVerticalPagePlacement(
|
||||
val pageIndex: Int,
|
||||
val topPx: Int,
|
||||
val widthPx: Int,
|
||||
val heightPx: Int
|
||||
) {
|
||||
val bottomPx: Int
|
||||
get() = topPx + heightPx
|
||||
}
|
||||
|
||||
data class PdfVerticalPageLayoutResult(
|
||||
val pages: List<PdfVerticalPagePlacement>,
|
||||
val totalHeightPx: Int
|
||||
)
|
||||
|
||||
fun calculatePdfVerticalPageLayoutPx(
|
||||
pageAspectRatios: List<Float>,
|
||||
viewportWidthPx: Int,
|
||||
viewportHeightPx: Int,
|
||||
pageGapPx: Int
|
||||
): PdfVerticalPageLayoutResult {
|
||||
val safeWidthPx = viewportWidthPx.coerceAtLeast(0)
|
||||
if (pageAspectRatios.isEmpty() || safeWidthPx == 0) {
|
||||
return PdfVerticalPageLayoutResult(emptyList(), 0)
|
||||
}
|
||||
|
||||
val safeGapPx = pageGapPx.coerceAtLeast(0)
|
||||
val safeHeightPx = viewportHeightPx.coerceAtLeast(0)
|
||||
|
||||
fun pageHeightPx(ratio: Float): Int {
|
||||
val safeRatio = if (ratio <= 0f) 1f else ratio
|
||||
return floor(safeWidthPx.toDouble() / safeRatio.toDouble())
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
var currentTopPx = 0
|
||||
if (pageAspectRatios.size == 1) {
|
||||
val singlePageHeightPx = pageHeightPx(pageAspectRatios[0])
|
||||
if (singlePageHeightPx < safeHeightPx) {
|
||||
currentTopPx = (safeHeightPx - singlePageHeightPx) / 2
|
||||
}
|
||||
}
|
||||
|
||||
val pages = pageAspectRatios.mapIndexed { index, ratio ->
|
||||
val heightPx = pageHeightPx(ratio)
|
||||
val placement = PdfVerticalPagePlacement(
|
||||
pageIndex = index,
|
||||
topPx = currentTopPx,
|
||||
widthPx = safeWidthPx,
|
||||
heightPx = heightPx
|
||||
)
|
||||
currentTopPx += heightPx
|
||||
if (index < pageAspectRatios.lastIndex) {
|
||||
currentTopPx += safeGapPx
|
||||
}
|
||||
placement
|
||||
}
|
||||
|
||||
return PdfVerticalPageLayoutResult(
|
||||
pages = pages,
|
||||
totalHeightPx = pages.lastOrNull()?.bottomPx ?: 0
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import kotlinx.serialization.json.jsonArray
|
|||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlin.math.pow
|
||||
|
||||
object SharedPdfAnnotationSidecarCodec {
|
||||
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
|
||||
|
|
@ -24,8 +23,6 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
const val KEY_LEGACY_TEXT_BOXES = "textBoxes"
|
||||
const val KEY_LEGACY_HIGHLIGHTS = "highlights"
|
||||
|
||||
private const val LEGACY_TEXT_BOX_FONT_REFERENCE_DP = 500f
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
|
|
@ -56,7 +53,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
|
||||
if (data[KEY_PDF_ANNOTATIONS] != null) return data
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty()) return data
|
||||
if (annotations.isEmpty() && !data.hasLegacyAndroidAnnotationPayload()) return data
|
||||
return JsonObject(data + (KEY_PDF_ANNOTATIONS to encodeAnnotationsElement(annotations)))
|
||||
}
|
||||
|
||||
|
|
@ -69,16 +66,17 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
annotations: List<SharedPdfAnnotation>,
|
||||
existingData: JsonObject = JsonObject(emptyMap())
|
||||
): JsonObject {
|
||||
if (annotations.isEmpty()) return existingData
|
||||
|
||||
val next = existingData.toMutableMap()
|
||||
if (!existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) {
|
||||
val replaceLegacyFromCanonical = existingData[KEY_PDF_ANNOTATIONS] != null
|
||||
if (annotations.isEmpty() && !replaceLegacyFromCanonical) return existingData
|
||||
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) {
|
||||
next[KEY_LEGACY_INK] = annotations.toLegacyAndroidInkArray()
|
||||
}
|
||||
if (!existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) {
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) {
|
||||
next[KEY_LEGACY_TEXT_BOXES] = annotations.toLegacyAndroidTextBoxArray()
|
||||
}
|
||||
if (!existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) {
|
||||
if (replaceLegacyFromCanonical || !existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) {
|
||||
next[KEY_LEGACY_HIGHLIGHTS] = annotations.toLegacyAndroidHighlightArray()
|
||||
}
|
||||
return JsonObject(next)
|
||||
|
|
@ -87,7 +85,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
fun legacyAndroidDataJsonFromCanonical(rawDataJson: String): String {
|
||||
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty()) return rawDataJson
|
||||
if (annotations.isEmpty() && data[KEY_PDF_ANNOTATIONS] == null) return rawDataJson
|
||||
return json.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
legacyAndroidDataFromAnnotations(annotations, data)
|
||||
|
|
@ -128,6 +126,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
kind = PdfAnnotationKind.INK,
|
||||
tool = tool.toPdfInkTool(),
|
||||
points = points,
|
||||
note = obj.string("note"),
|
||||
colorArgb = obj.int("color") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
|
||||
strokeWidth = obj.float("strokeWidth") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
createdAt = points.firstOrNull()?.timestamp ?: 0L
|
||||
|
|
@ -151,7 +150,8 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
colorArgb = obj.int("color") ?: 0xFF000000.toInt(),
|
||||
backgroundArgb = obj.int("backgroundColor") ?: 0x00000000,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = rawFontSize.legacyTextBoxFontSizeToShared(),
|
||||
fontSize = SharedPdfTextAnnotationDefaults.pageRelativeFontSizeToDisplay(rawFontSize),
|
||||
pageRelativeFontSize = SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(rawFontSize),
|
||||
isBold = obj.boolean("isBold") ?: false,
|
||||
isItalic = obj.boolean("isItalic") ?: false,
|
||||
isUnderline = obj.boolean("isUnderline") ?: false,
|
||||
|
|
@ -189,7 +189,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
boundsList = boundsList,
|
||||
text = obj.string("text").orEmpty(),
|
||||
note = obj.string("note"),
|
||||
colorArgb = colorName.toSharedHighlightArgb(),
|
||||
colorArgb = SharedPdfAndroidHighlightColors.argbForName(colorName),
|
||||
rangeStartIndex = rangeStart,
|
||||
rangeEndIndex = inclusiveRangeEnd
|
||||
)
|
||||
|
|
@ -208,6 +208,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
put("inkType", JsonPrimitive(annotation.tool.name))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("strokeWidth", JsonPrimitive(annotation.strokeWidth.toDouble()))
|
||||
annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) }
|
||||
put(
|
||||
"points",
|
||||
JsonArray(
|
||||
|
|
@ -240,7 +241,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
put("text", JsonPrimitive(annotation.text))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("backgroundColor", JsonPrimitive(annotation.backgroundArgb))
|
||||
put("fontSize", JsonPrimitive(annotation.fontSize.sharedFontSizeToLegacyTextBox().toDouble()))
|
||||
put("fontSize", JsonPrimitive(annotation.sharedPdfTextPageRelativeFontSize().toDouble()))
|
||||
put("isBold", JsonPrimitive(annotation.isBold))
|
||||
put("isItalic", JsonPrimitive(annotation.isItalic))
|
||||
put("isUnderline", JsonPrimitive(annotation.isUnderline))
|
||||
|
|
@ -262,7 +263,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("color", JsonPrimitive(annotation.colorArgb.toLegacyHighlightColorName()))
|
||||
put("color", JsonPrimitive(SharedPdfAndroidHighlightColors.nearestName(annotation.colorArgb)))
|
||||
put("text", JsonPrimitive(annotation.text))
|
||||
val rangeStart = annotation.rangeStartIndex ?: 0
|
||||
val rangeEnd = annotation.rangeEndIndex?.plus(1)?.coerceAtLeast(rangeStart) ?: rangeStart
|
||||
|
|
@ -305,6 +306,12 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
|
||||
private fun JsonElement?.isJsonArray(): Boolean = this?.jsonArrayOrNull() != null
|
||||
|
||||
private fun JsonObject.hasLegacyAndroidAnnotationPayload(): Boolean {
|
||||
return this[KEY_LEGACY_INK] != null ||
|
||||
this[KEY_LEGACY_TEXT_BOXES] != null ||
|
||||
this[KEY_LEGACY_HIGHLIGHTS] != null
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonArray }.getOrNull()
|
||||
|
|
@ -378,38 +385,4 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
return runCatching { PdfInkTool.valueOf(this) }.getOrDefault(PdfInkTool.PEN)
|
||||
}
|
||||
|
||||
private fun Float.legacyTextBoxFontSizeToShared(): Float {
|
||||
return if (this in 0f..1f) {
|
||||
(this * LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(8f, 48f)
|
||||
} else {
|
||||
coerceIn(8f, 96f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.sharedFontSizeToLegacyTextBox(): Float {
|
||||
return (this / LEGACY_TEXT_BOX_FONT_REFERENCE_DP).coerceIn(0.012f, 0.12f)
|
||||
}
|
||||
|
||||
private fun String.toSharedHighlightArgb(): Int {
|
||||
val opaqueArgb = legacyHighlightColors[uppercase()] ?: legacyHighlightColors.getValue("YELLOW")
|
||||
return 0x8C000000.toInt() or (opaqueArgb and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
private fun Int.toLegacyHighlightColorName(): String {
|
||||
val rgb = this and 0x00FFFFFF
|
||||
return legacyHighlightColors.minByOrNull { (_, color) ->
|
||||
val candidate = color and 0x00FFFFFF
|
||||
val dr = ((rgb shr 16) and 0xFF) - ((candidate shr 16) and 0xFF)
|
||||
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
|
||||
val db = (rgb and 0xFF) - (candidate and 0xFF)
|
||||
dr.toDouble().pow(2) + dg.toDouble().pow(2) + db.toDouble().pow(2)
|
||||
}?.key ?: "YELLOW"
|
||||
}
|
||||
|
||||
private val legacyHighlightColors = mapOf(
|
||||
"YELLOW" to 0xFFFBC02D.toInt(),
|
||||
"GREEN" to 0xFF388E3C.toInt(),
|
||||
"BLUE" to 0xFF1976D2.toInt(),
|
||||
"RED" to 0xFFD32F2F.toInt()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ object SharedPdfInkRenderer {
|
|||
canvasSize: IntSize
|
||||
): SharedPdfInkRenderData? {
|
||||
if (annotation.kind != PdfAnnotationKind.INK || annotation.points.isEmpty()) return null
|
||||
if (annotation.tool == PdfInkTool.NONE) return null
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val strokeWidthPx = effectiveStrokeWidthPx(annotation.strokeWidth, widthPx)
|
||||
|
|
@ -87,6 +88,7 @@ object SharedPdfInkRenderer {
|
|||
}
|
||||
|
||||
return when (annotation.tool) {
|
||||
PdfInkTool.NONE -> null
|
||||
PdfInkTool.PENCIL -> {
|
||||
val path = annotation.points.toSmoothPath(widthPx, heightPx)
|
||||
val velocityAlpha = annotation.points.velocityAlpha(widthPx, heightPx)
|
||||
|
|
@ -335,6 +337,7 @@ object SharedPdfInkRenderer {
|
|||
|
||||
fun PdfInkTool.sharedPdfStrokeWidthRange(): ClosedFloatingPointRange<Float> {
|
||||
return when (this) {
|
||||
PdfInkTool.NONE -> 0.001f..0.015f
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> 0.01f..0.06f
|
||||
PdfInkTool.ERASER -> 0.002f..0.10f
|
||||
|
|
|
|||
|
|
@ -45,21 +45,17 @@ import kotlinx.serialization.json.intOrNull
|
|||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.math.abs
|
||||
|
||||
const val SHARED_PDF_PAGE_BREAK_CHAR: Char = '\u000C'
|
||||
|
||||
private const val SHARED_PDF_ZWSP = "\u200B"
|
||||
private const val SHARED_PDF_RICH_FONT_PATH_TAG = "pdf-rich-font-path"
|
||||
|
||||
const val SHARED_PDF_RICH_TEXT_LOG_TAG: String = "PdfRichTextTrace"
|
||||
|
||||
object SharedPdfRichTextLog {
|
||||
var enabled: Boolean = true
|
||||
|
||||
fun d(message: String) {
|
||||
if (enabled) {
|
||||
println("$SHARED_PDF_RICH_TEXT_LOG_TAG $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -621,10 +617,26 @@ class SharedPdfRichTextController(
|
|||
|
||||
fun updateLayoutConfig(width: Float, height: Float, density: Density, measurer: TextMeasurer) {
|
||||
if (lastPageWidth != width || lastPageHeight != height || lastDensity != density || lastTextMeasurer != measurer) {
|
||||
val previousPageHeight = lastPageHeight
|
||||
val fontScale = if (previousPageHeight > 0f && height > 0f) {
|
||||
height / previousPageHeight
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
val shouldScaleFonts = abs(fontScale - 1f) > 0.001f
|
||||
SharedPdfRichTextLog.d(
|
||||
"controller.layoutConfig width=${width.richLogFloat()} height=${height.richLogFloat()} " +
|
||||
"density=${density.density.richLogFloat()} old=${lastPageWidth.richLogFloat()}x${lastPageHeight.richLogFloat()}"
|
||||
"density=${density.density.richLogFloat()} old=${lastPageWidth.richLogFloat()}x${previousPageHeight.richLogFloat()} " +
|
||||
"fontScale=${fontScale.richLogFloat()}"
|
||||
)
|
||||
if (shouldScaleFonts) {
|
||||
saveJob?.cancel()
|
||||
globalTextFieldValue = globalTextFieldValue.withScaledSharedPdfRichFontSizes(fontScale)
|
||||
localTextFieldValue = localTextFieldValue.withScaledSharedPdfRichFontSizes(fontScale)
|
||||
if (globalTextFieldValue.text.isNotEmpty()) {
|
||||
debouncedSave(globalTextFieldValue)
|
||||
}
|
||||
}
|
||||
lastPageWidth = width
|
||||
lastPageHeight = height
|
||||
lastDensity = density
|
||||
|
|
@ -1534,6 +1546,45 @@ fun SharedPdfTextStyleConfig.toSharedPdfRichSpanStyle(): SpanStyle {
|
|||
)
|
||||
}
|
||||
|
||||
internal fun AnnotatedString.withScaledSharedPdfRichFontSizes(scale: Float): AnnotatedString {
|
||||
if (!scale.isFinite() || scale <= 0f || abs(scale - 1f) <= 0.001f) return this
|
||||
if (spanStyles.none { it.item.fontSize.isSp }) return this
|
||||
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
spanStyles.forEach { range ->
|
||||
val style = range.item
|
||||
builder.addStyle(
|
||||
style = if (style.fontSize.isSp) {
|
||||
style.copy(fontSize = (style.fontSize.value * scale).sp)
|
||||
} else {
|
||||
style
|
||||
},
|
||||
start = range.start,
|
||||
end = range.end
|
||||
)
|
||||
}
|
||||
paragraphStyles.forEach { range ->
|
||||
builder.addStyle(range.item, range.start, range.end)
|
||||
}
|
||||
getStringAnnotations(
|
||||
tag = SHARED_PDF_RICH_FONT_PATH_TAG,
|
||||
start = 0,
|
||||
end = length
|
||||
).forEach { annotation ->
|
||||
builder.addStringAnnotation(
|
||||
tag = annotation.tag,
|
||||
annotation = annotation.item,
|
||||
start = annotation.start,
|
||||
end = annotation.end
|
||||
)
|
||||
}
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
private fun TextFieldValue.withScaledSharedPdfRichFontSizes(scale: Float): TextFieldValue {
|
||||
return copy(annotatedString = annotatedString.withScaledSharedPdfRichFontSizes(scale))
|
||||
}
|
||||
|
||||
fun SharedPdfRichTextController.currentSharedPdfTextStyleConfig(): SharedPdfTextStyleConfig {
|
||||
val decoration = currentStyle.textDecoration ?: TextDecoration.None
|
||||
return SharedPdfTextStyleConfig(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ data class SharedPdfTextStyleConfig(
|
|||
val colorArgb: Int = 0xFF000000.toInt(),
|
||||
val backgroundColorArgb: Int = 0x00000000,
|
||||
val fontSize: Float = 16f,
|
||||
val pageRelativeFontSize: Float? = null,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
|
|
@ -46,6 +47,10 @@ data class SharedPdfTextDraft(
|
|||
)
|
||||
|
||||
object SharedPdfTextAnnotationDefaults {
|
||||
private const val AndroidTextBoxFontReferencePx = 500f
|
||||
private const val MinPageRelativeFontSize = 0.012f
|
||||
private const val MaxPageRelativeFontSize = 0.12f
|
||||
|
||||
val fontSizes: List<Float> = listOf(12f, 14f, 16f, 18f, 20f, 24f, 30f)
|
||||
|
||||
val fontPresets: List<SharedPdfTextFontPreset> = listOf(
|
||||
|
|
@ -97,6 +102,7 @@ object SharedPdfTextAnnotationDefaults {
|
|||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
|
|
@ -133,9 +139,10 @@ object SharedPdfTextAnnotationDefaults {
|
|||
): PdfPageBounds {
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val widthNorm = estimateWidthNorm(text, style, widthPx).coerceIn(0.18f, 0.62f)
|
||||
val lineCount = estimateLineCount(text, style.fontSize, widthPx * widthNorm)
|
||||
val heightNorm = (((style.fontSize * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f)
|
||||
val fontSizePx = style.sharedPdfTextFontSizePx(canvasSize)
|
||||
val widthNorm = estimateWidthNorm(text, fontSizePx, widthPx).coerceIn(0.18f, 0.62f)
|
||||
val lineCount = estimateLineCount(text, fontSizePx, widthPx * widthNorm)
|
||||
val heightNorm = (((fontSizePx * 1.35f * lineCount) + 14f) / heightPx).coerceIn(0.04f, 0.36f)
|
||||
val left = anchor.x.coerceIn(0f, 1f - widthNorm)
|
||||
val top = anchor.y.coerceIn(0f, 1f - heightNorm)
|
||||
return PdfPageBounds(
|
||||
|
|
@ -158,13 +165,34 @@ object SharedPdfTextAnnotationDefaults {
|
|||
|
||||
private fun estimateWidthNorm(
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
fontSizePx: Float,
|
||||
pageWidthPx: Float
|
||||
): Float {
|
||||
val longestLine = text.lineSequence().maxOfOrNull { it.length } ?: 0
|
||||
val estimatedTextWidth = (longestLine.coerceAtLeast(12) * style.fontSize * 0.55f) + 18f
|
||||
val estimatedTextWidth = (longestLine.coerceAtLeast(12) * fontSizePx * 0.55f) + 18f
|
||||
return (estimatedTextWidth / pageWidthPx).coerceAtLeast(0.28f)
|
||||
}
|
||||
|
||||
internal fun displayFontSizeToPageRelative(fontSize: Float): Float {
|
||||
return (fontSize / AndroidTextBoxFontReferencePx)
|
||||
.coerceIn(MinPageRelativeFontSize, MaxPageRelativeFontSize)
|
||||
}
|
||||
|
||||
internal fun pageRelativeFontSizeToDisplay(fontSize: Float): Float {
|
||||
return if (fontSize in 0f..1f) {
|
||||
(fontSize * AndroidTextBoxFontReferencePx).coerceIn(8f, 48f)
|
||||
} else {
|
||||
fontSize.coerceIn(8f, 96f)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun legacyFontSizeToPageRelative(fontSize: Float): Float {
|
||||
return if (fontSize in 0f..1f) {
|
||||
fontSize.coerceIn(MinPageRelativeFontSize, MaxPageRelativeFontSize)
|
||||
} else {
|
||||
displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withText(
|
||||
|
|
@ -225,6 +253,7 @@ fun SharedPdfTextDraft.toAnnotation(): SharedPdfAnnotation {
|
|||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
|
|
@ -316,6 +345,7 @@ fun SharedPdfAnnotation.sharedPdfTextStyle(): SharedPdfTextStyleConfig {
|
|||
colorArgb = colorArgb,
|
||||
backgroundColorArgb = backgroundArgb,
|
||||
fontSize = fontSize,
|
||||
pageRelativeFontSize = pageRelativeFontSize,
|
||||
isBold = isBold,
|
||||
isItalic = isItalic,
|
||||
isUnderline = isUnderline,
|
||||
|
|
@ -330,6 +360,7 @@ fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig):
|
|||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
fontSize = style.fontSize,
|
||||
pageRelativeFontSize = style.sharedPdfTextPageRelativeFontSize(),
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
|
|
@ -339,6 +370,35 @@ fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig):
|
|||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.withSharedPdfTextFontSize(fontSize: Float): SharedPdfTextStyleConfig {
|
||||
return copy(
|
||||
fontSize = fontSize,
|
||||
pageRelativeFontSize = SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.sharedPdfTextPageRelativeFontSize(): Float {
|
||||
return pageRelativeFontSize
|
||||
?.let { SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(it) }
|
||||
?: SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
|
||||
fun SharedPdfTextStyleConfig.sharedPdfTextFontSizePx(canvasSize: IntSize): Float {
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
return (sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextPageRelativeFontSize(): Float {
|
||||
return pageRelativeFontSize
|
||||
?.let { SharedPdfTextAnnotationDefaults.legacyFontSizeToPageRelative(it) }
|
||||
?: SharedPdfTextAnnotationDefaults.displayFontSizeToPageRelative(fontSize)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextFontSizePx(canvasSize: IntSize): Float {
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
return (sharedPdfTextPageRelativeFontSize() * pageHeightPx).coerceAtLeast(1f)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.coercedToPage(): PdfPageBounds {
|
||||
val coercedLeft = left.coerceIn(0f, 1f)
|
||||
val coercedTop = top.coerceIn(0f, 1f)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ data class ReaderSessionState(
|
|||
val searchResults: List<ReaderSearchResult> = emptyList(),
|
||||
val activeSearchResultIndex: Int = -1,
|
||||
val navigationLocator: ReaderLocator? = null,
|
||||
val navigationRequestId: Long = 0L
|
||||
val navigationRequestId: Long = 0L,
|
||||
val jumpHistory: ReaderJumpHistory = ReaderJumpHistory()
|
||||
) {
|
||||
val currentBookmark: ReaderBookmark?
|
||||
get() = navigationLocator
|
||||
|
|
@ -84,7 +85,7 @@ class ReaderEngine(
|
|||
private data class PaginationCacheKey(
|
||||
val bookId: String,
|
||||
val chapterSignature: Int,
|
||||
val settings: ReaderSettings
|
||||
val layoutSignature: ReaderLayoutSignature
|
||||
)
|
||||
|
||||
private val paginationCache = object : LinkedHashMap<PaginationCacheKey, List<ReaderPage>>(8, 0.75f, true) {
|
||||
|
|
@ -97,11 +98,16 @@ class ReaderEngine(
|
|||
book: SharedEpubBook,
|
||||
settings: ReaderSettings = ReaderSettings(),
|
||||
initialPageIndex: Int = 0,
|
||||
initialLocator: ReaderLocator? = null,
|
||||
bookmarks: List<ReaderBookmark> = emptyList(),
|
||||
highlights: List<UserHighlight> = emptyList()
|
||||
): ReaderSessionState {
|
||||
val pages = pagesFor(book, settings)
|
||||
val initialIndex = initialPageIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))
|
||||
val requestedInitialIndex = initialLocator
|
||||
?.let { pages.findPageIndexForLocator(it) }
|
||||
?.takeIf { it >= 0 }
|
||||
?: initialPageIndex
|
||||
val initialIndex = ReaderSpreadLayout.normalizePageIndex(requestedInitialIndex, pages.size, settings)
|
||||
val reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = pages,
|
||||
|
|
@ -118,22 +124,30 @@ class ReaderEngine(
|
|||
.map { it.withNormalizedLocator() }
|
||||
.filter { (it.locator.chapterIndex ?: it.chapterIndex) in book.chapters.indices }
|
||||
.distinctBy { it.id },
|
||||
navigationLocator = reader.currentPage?.toLocator(book)
|
||||
navigationLocator = initialLocator
|
||||
?.normalizedForResolvedPage(book, pages, requestedInitialIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)))
|
||||
?: reader.currentPage?.toLocator(book)
|
||||
)
|
||||
}
|
||||
|
||||
fun next(state: ReaderSessionState): ReaderSessionState {
|
||||
if (!state.reader.canGoNext) return state
|
||||
return goToPage(state, state.reader.currentPageIndex + 1)
|
||||
return goToPage(
|
||||
state,
|
||||
ReaderSpreadLayout.nextPageIndex(state.reader.currentPageIndex, state.reader.pages.size, state.reader.settings)
|
||||
)
|
||||
}
|
||||
|
||||
fun previous(state: ReaderSessionState): ReaderSessionState {
|
||||
if (!state.reader.canGoPrevious) return state
|
||||
return goToPage(state, state.reader.currentPageIndex - 1)
|
||||
return goToPage(
|
||||
state,
|
||||
ReaderSpreadLayout.previousPageIndex(state.reader.currentPageIndex, state.reader.pages.size, state.reader.settings)
|
||||
)
|
||||
}
|
||||
|
||||
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
|
||||
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(target)
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
|
|
@ -159,31 +173,85 @@ class ReaderEngine(
|
|||
}
|
||||
|
||||
fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
|
||||
val pageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
val requestedPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: return state
|
||||
val pageIndex = ReaderSpreadLayout.normalizePageIndex(requestedPageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(pageIndex) ?: return state
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = locator.textQuote ?: page.text.preview(),
|
||||
cfi = locator.cfi ?: page.toDesktopCfi()
|
||||
val requestedPage = state.reader.pages.getOrNull(requestedPageIndex) ?: page
|
||||
val requestedChapter = state.reader.book.chapters.getOrNull(requestedPage.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = requestedPageIndex).withFallbacks(
|
||||
chapterIndex = requestedPage.chapterIndex,
|
||||
chapterId = requestedChapter?.id,
|
||||
href = requestedChapter?.baseHref,
|
||||
pageIndex = requestedPageIndex,
|
||||
startOffset = requestedPage.startOffset,
|
||||
endOffset = requestedPage.endOffset,
|
||||
textQuote = locator.textQuote ?: requestedPage.text.preview(),
|
||||
cfi = locator.cfi ?: requestedPage.toDesktopCfi()
|
||||
)
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = pageIndex),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == requestedPageIndex },
|
||||
navigationLocator = normalizedLocator,
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
|
||||
fun jumpToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
|
||||
return goToPage(state, pageIndex).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpToPageNumber(state: ReaderSessionState, pageNumber: Int): ReaderSessionState {
|
||||
return jumpToPage(state, pageNumber - 1)
|
||||
}
|
||||
|
||||
fun jumpToChapter(state: ReaderSessionState, chapterIndex: Int): ReaderSessionState {
|
||||
return goToChapter(state, chapterIndex).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
|
||||
return goToLocator(state, locator).withRecordedJumpFrom(state, requestedTarget = locator)
|
||||
}
|
||||
|
||||
fun jumpToSearchResult(state: ReaderSessionState, resultIndex: Int): ReaderSessionState {
|
||||
return goToSearchResult(state, resultIndex).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpToNextSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
return nextSearchResult(state).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpToPreviousSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
return previousSearchResult(state).withRecordedJumpFrom(state)
|
||||
}
|
||||
|
||||
fun jumpBack(state: ReaderSessionState): ReaderSessionState {
|
||||
if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) {
|
||||
return state.copy(jumpHistory = state.jumpHistory.clear())
|
||||
}
|
||||
val history = state.jumpHistory.pruned(state.reader.book.chapters.size)
|
||||
val target = history.backLocator ?: return state.copy(jumpHistory = history)
|
||||
return goToLocator(state.copy(jumpHistory = history), target)
|
||||
.copy(jumpHistory = history.stepBack())
|
||||
}
|
||||
|
||||
fun jumpForward(state: ReaderSessionState): ReaderSessionState {
|
||||
if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) {
|
||||
return state.copy(jumpHistory = state.jumpHistory.clear())
|
||||
}
|
||||
val history = state.jumpHistory.pruned(state.reader.book.chapters.size)
|
||||
val target = history.forwardLocator ?: return state.copy(jumpHistory = history)
|
||||
return goToLocator(state.copy(jumpHistory = history), target)
|
||||
.copy(jumpHistory = history.stepForward())
|
||||
}
|
||||
|
||||
fun clearJumpHistory(state: ReaderSessionState): ReaderSessionState {
|
||||
return state.copy(jumpHistory = state.jumpHistory.clear())
|
||||
}
|
||||
|
||||
fun resolveLink(
|
||||
state: ReaderSessionState,
|
||||
href: String,
|
||||
|
|
@ -275,43 +343,112 @@ class ReaderEngine(
|
|||
}
|
||||
|
||||
fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState {
|
||||
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val normalizedLocator = locator?.normalizedForPage(state, target)
|
||||
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val normalizedLocator = locator?.normalizedForPage(state, pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)))
|
||||
if (target == state.reader.currentPageIndex && normalizedLocator == null) return state
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
|
||||
navigationLocator = normalizedLocator ?: state.navigationLocator
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
|
||||
val current = state.reader.currentPage
|
||||
val pages = pagesFor(state.reader.book, settings)
|
||||
val newIndex = if (current == null) {
|
||||
0
|
||||
val layoutChanged = state.reader.settings.layoutSignature() != settings.layoutSignature()
|
||||
val nextJumpHistory = if (settings.readingMode == ReaderReadingMode.PAGINATED) {
|
||||
state.jumpHistory.clear()
|
||||
} else {
|
||||
pages.indexOfFirst {
|
||||
it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset
|
||||
}.takeIf { it >= 0 } ?: 0
|
||||
state.jumpHistory
|
||||
}
|
||||
if (!layoutChanged) {
|
||||
return state.copy(
|
||||
reader = state.reader.copy(settings = settings),
|
||||
jumpHistory = nextJumpHistory
|
||||
)
|
||||
}
|
||||
val anchor = state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book)
|
||||
val pages = pagesFor(state.reader.book, settings)
|
||||
val requestedIndex = anchor
|
||||
?.let { pages.findPageIndexForLocator(it) }
|
||||
?.takeIf { it >= 0 }
|
||||
?: 0
|
||||
val newIndex = ReaderSpreadLayout.normalizePageIndex(requestedIndex, pages.size, settings)
|
||||
val normalizedLocator = anchor
|
||||
?.normalizedForResolvedPage(state.reader.book, pages, requestedIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)))
|
||||
?: pages.getOrNull(newIndex)?.toLocator(state.reader.book)
|
||||
val updated = state.copy(
|
||||
reader = state.reader.copy(
|
||||
pages = pages,
|
||||
currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)),
|
||||
currentPageIndex = newIndex,
|
||||
settings = settings
|
||||
)
|
||||
),
|
||||
navigationLocator = normalizedLocator,
|
||||
jumpHistory = nextJumpHistory
|
||||
)
|
||||
return if (updated.searchQuery.isNotBlank()) search(updated, updated.searchQuery) else updated
|
||||
}
|
||||
|
||||
fun reflowAnchorFor(state: ReaderSessionState): ReaderLocator? {
|
||||
return state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book)
|
||||
}
|
||||
|
||||
fun replacePages(
|
||||
state: ReaderSessionState,
|
||||
pages: List<ReaderPage>,
|
||||
reflowAnchor: ReaderLocator? = null,
|
||||
navigationRequestIdAtReflowStart: Long? = null
|
||||
): ReaderSessionState {
|
||||
if (pages.isEmpty()) return state
|
||||
val explicitNavigationAfterReflowStarted = navigationRequestIdAtReflowStart != null &&
|
||||
state.navigationRequestId != navigationRequestIdAtReflowStart
|
||||
val anchor = when {
|
||||
explicitNavigationAfterReflowStarted ->
|
||||
state.navigationLocator ?: state.activeSearchResult?.locator ?: state.reader.currentPage?.toLocator(state.reader.book)
|
||||
reflowAnchor != null -> reflowAnchor
|
||||
else -> state.navigationLocator ?: state.reader.currentPage?.toLocator(state.reader.book)
|
||||
}
|
||||
val targetIndex = anchor
|
||||
?.let { locator -> pages.findPageIndexForLocator(locator) }
|
||||
?.takeIf { it >= 0 }
|
||||
?: state.reader.currentPage?.let { current ->
|
||||
pages.indexOfFirst {
|
||||
it.chapterIndex == current.chapterIndex &&
|
||||
it.startOffset <= current.startOffset &&
|
||||
it.endOffset >= current.startOffset
|
||||
}.takeIf { it >= 0 }
|
||||
}
|
||||
?: state.reader.currentPageIndex
|
||||
val requestedIndex = targetIndex.coerceIn(0, pages.lastIndex)
|
||||
val normalizedIndex = ReaderSpreadLayout.normalizePageIndex(requestedIndex, pages.size, state.reader.settings)
|
||||
val normalizedLocator = anchor
|
||||
?.normalizedForResolvedPage(state.reader.book, pages, requestedIndex)
|
||||
?: pages.getOrNull(normalizedIndex)?.toLocator(state.reader.book)
|
||||
val activeSearchIndex = normalizedLocator
|
||||
?.let { locator -> state.searchResults.indexOfFirst { it.locator.sameLocation(locator) } }
|
||||
?: -1
|
||||
val updated = state.copy(
|
||||
reader = state.reader.copy(
|
||||
pages = pages,
|
||||
currentPageIndex = normalizedIndex
|
||||
),
|
||||
activeSearchResultIndex = activeSearchIndex,
|
||||
navigationLocator = normalizedLocator,
|
||||
jumpHistory = if (state.reader.settings.readingMode == ReaderReadingMode.PAGINATED) {
|
||||
state.jumpHistory.clear()
|
||||
} else {
|
||||
state.jumpHistory
|
||||
}
|
||||
)
|
||||
return if (updated.searchQuery.isNotBlank()) refreshSearchResults(updated) else updated
|
||||
}
|
||||
|
||||
private fun pagesFor(book: SharedEpubBook, settings: ReaderSettings): List<ReaderPage> {
|
||||
val key = PaginationCacheKey(
|
||||
bookId = book.id,
|
||||
chapterSignature = book.chapters.fold(1) { acc, chapter ->
|
||||
31 * acc + chapter.id.hashCode() + chapter.plainText.length + chapter.plainText.hashCode()
|
||||
},
|
||||
settings = settings
|
||||
layoutSignature = settings.layoutSignature()
|
||||
)
|
||||
return synchronized(paginationCache) {
|
||||
paginationCache.getOrPut(key) {
|
||||
|
|
@ -463,47 +600,59 @@ class ReaderEngine(
|
|||
|
||||
fun search(state: ReaderSessionState, query: String): ReaderSessionState {
|
||||
val normalized = query.trim()
|
||||
val results = if (normalized.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
state.reader.pages.flatMap { page ->
|
||||
val matches = mutableListOf<ReaderSearchResult>()
|
||||
var startIndex = 0
|
||||
while (startIndex < page.text.length) {
|
||||
val index = page.text.indexOfSearch(normalized, startIndex, state.searchOptions)
|
||||
if (index < 0) break
|
||||
val endIndex = (index + normalized.length).coerceAtMost(page.text.length)
|
||||
matches +=
|
||||
ReaderSearchResult(
|
||||
pageIndex = page.pageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.previewAround(index, normalized.length),
|
||||
matchIndex = index,
|
||||
chapterIndex = page.chapterIndex,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + index,
|
||||
endOffset = page.startOffset + endIndex,
|
||||
textQuote = page.text.substring(index, endIndex)
|
||||
)
|
||||
)
|
||||
startIndex = index + normalized.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
val activeIndex = results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }
|
||||
.takeIf { it >= 0 }
|
||||
?: if (results.isNotEmpty()) 0 else -1
|
||||
val updated = state.copy(
|
||||
val results = searchResultsFor(state, normalized)
|
||||
return state.copy(
|
||||
isSearchActive = state.isSearchActive || normalized.isNotBlank(),
|
||||
showSearchResultsPanel = state.showSearchResultsPanel || normalized.isNotBlank(),
|
||||
searchQuery = query,
|
||||
searchResults = results,
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshSearchResults(state: ReaderSessionState): ReaderSessionState {
|
||||
val normalized = state.searchQuery.trim()
|
||||
val results = searchResultsFor(state, normalized)
|
||||
val previousLocator = state.activeSearchResult?.locator
|
||||
val activeIndex = previousLocator
|
||||
?.let { locator -> results.indexOfFirst { it.locator.sameLocation(locator) } }
|
||||
?.takeIf { it >= 0 }
|
||||
?: results.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }.takeIf { it >= 0 }
|
||||
?: if (results.isNotEmpty()) 0 else -1
|
||||
return state.copy(
|
||||
searchResults = results,
|
||||
activeSearchResultIndex = activeIndex
|
||||
)
|
||||
return updated.activeSearchResult?.let { goToSearchResult(updated, activeIndex) } ?: updated
|
||||
}
|
||||
|
||||
private fun searchResultsFor(state: ReaderSessionState, normalized: String): List<ReaderSearchResult> {
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
return state.reader.pages.flatMap { page ->
|
||||
val matches = mutableListOf<ReaderSearchResult>()
|
||||
var startIndex = 0
|
||||
while (startIndex < page.text.length) {
|
||||
val index = page.text.indexOfSearch(normalized, startIndex, state.searchOptions)
|
||||
if (index < 0) break
|
||||
val endIndex = (index + normalized.length).coerceAtMost(page.text.length)
|
||||
matches +=
|
||||
ReaderSearchResult(
|
||||
pageIndex = page.pageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.previewAround(index, normalized.length),
|
||||
matchIndex = index,
|
||||
chapterIndex = page.chapterIndex,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + index,
|
||||
endOffset = page.startOffset + endIndex,
|
||||
textQuote = page.text.substring(index, endIndex)
|
||||
)
|
||||
)
|
||||
startIndex = index + normalized.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
fun nextSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
|
|
@ -530,25 +679,51 @@ class ReaderEngine(
|
|||
if (state.searchResults.isEmpty()) return state
|
||||
val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex)
|
||||
val result = state.searchResults[targetIndex]
|
||||
val targetPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) }
|
||||
val requestedPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val targetPage = ReaderSpreadLayout.normalizePageIndex(requestedPage, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(targetPage)
|
||||
val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) }
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = targetPage),
|
||||
activeSearchResultIndex = targetIndex,
|
||||
navigationLocator = result.locator.copy(pageIndex = targetPage).withFallbacks(
|
||||
navigationLocator = result.locator.copy(pageIndex = requestedPage).withFallbacks(
|
||||
chapterIndex = page?.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = targetPage
|
||||
pageIndex = requestedPage
|
||||
),
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderSessionState.withRecordedJumpFrom(
|
||||
previous: ReaderSessionState,
|
||||
requestedTarget: ReaderLocator? = null
|
||||
): ReaderSessionState {
|
||||
if (
|
||||
previous.reader.settings.readingMode == ReaderReadingMode.PAGINATED ||
|
||||
reader.settings.readingMode == ReaderReadingMode.PAGINATED
|
||||
) {
|
||||
return copy(jumpHistory = previous.jumpHistory.clear())
|
||||
}
|
||||
val current = previous.currentJumpLocator()
|
||||
val target = navigationLocator ?: requestedTarget ?: currentJumpLocator()
|
||||
return copy(
|
||||
jumpHistory = previous.jumpHistory.record(
|
||||
currentLocator = current,
|
||||
targetLocator = target,
|
||||
chapterCount = reader.book.chapters.size
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderSessionState.currentJumpLocator(): ReaderLocator? {
|
||||
return navigationLocator ?: reader.currentPage?.toLocator(reader.book)
|
||||
}
|
||||
|
||||
private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
||||
val targetChapter = locator.chapterIndex
|
||||
if (targetChapter != null && targetChapter != chapterIndex) return false
|
||||
|
|
@ -565,6 +740,34 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
|||
return targetPage != null && targetPage == pageIndex
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.findPageIndexForLocator(locator: ReaderLocator): Int {
|
||||
return indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex?.takeIf { it in indices }
|
||||
?: -1
|
||||
}
|
||||
|
||||
private fun ReaderLocator.normalizedForResolvedPage(
|
||||
book: SharedEpubBook,
|
||||
pages: List<ReaderPage>,
|
||||
pageIndex: Int
|
||||
): ReaderLocator? {
|
||||
val page = pages.getOrNull(pageIndex) ?: return null
|
||||
val chapter = book.chapters.getOrNull(page.chapterIndex)
|
||||
val start = startOffset ?: page.startOffset
|
||||
val end = (endOffset ?: start).coerceAtLeast(start)
|
||||
return copy(pageIndex = page.pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = start,
|
||||
endOffset = end,
|
||||
textQuote = textQuote ?: page.text.preview(),
|
||||
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<ReaderPage>): ReaderBookmark? {
|
||||
val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
|
|
@ -796,5 +999,5 @@ private fun Char?.isWordChar(): Boolean {
|
|||
}
|
||||
|
||||
private fun logReaderLink(message: String) {
|
||||
println("ReaderLinkResolve $message")
|
||||
logSharedReaderDiagnostic("ReaderLinkResolve") { message }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,125 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
data class ReaderJumpHistory(
|
||||
val locators: List<ReaderLocator> = emptyList(),
|
||||
val cursor: Int = -1,
|
||||
val maxEntries: Int = 21
|
||||
) {
|
||||
val backLocator: ReaderLocator? get() = locators.getOrNull(cursor - 1)
|
||||
val forwardLocator: ReaderLocator? get() = locators.getOrNull(cursor + 1)
|
||||
val hasJumpTargets: Boolean get() = backLocator != null || forwardLocator != null
|
||||
|
||||
fun record(
|
||||
currentLocator: ReaderLocator?,
|
||||
targetLocator: ReaderLocator?,
|
||||
chapterCount: Int
|
||||
): ReaderJumpHistory {
|
||||
val current = currentLocator?.takeIf { it.isValidJumpLocator(chapterCount) } ?: return this
|
||||
val target = targetLocator?.takeIf { it.isValidJumpLocator(chapterCount) } ?: return this
|
||||
if (current.hasSameJumpLocation(target)) return this
|
||||
|
||||
val pruned = pruned(chapterCount)
|
||||
val nextLocators = pruned.locators.toMutableList()
|
||||
var nextCursor = pruned.cursor
|
||||
|
||||
while (nextLocators.lastIndex > nextCursor) {
|
||||
nextLocators.removeAt(nextLocators.lastIndex)
|
||||
}
|
||||
|
||||
if (nextCursor > 0 && nextLocators.getOrNull(nextCursor - 1)?.hasSameJumpLocation(current) == true) {
|
||||
nextLocators[nextCursor] = target
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
if (nextCursor == -1 || nextLocators.getOrNull(nextCursor)?.hasSameJumpLocation(current) != true) {
|
||||
nextLocators += current
|
||||
nextCursor = nextLocators.lastIndex
|
||||
}
|
||||
|
||||
if (nextLocators.lastOrNull()?.hasSameJumpLocation(target) != true) {
|
||||
nextLocators += target
|
||||
nextCursor = nextLocators.lastIndex
|
||||
}
|
||||
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun pruned(chapterCount: Int): ReaderJumpHistory {
|
||||
if (chapterCount <= 0) return clear()
|
||||
val nextLocators = locators.toMutableList()
|
||||
var nextCursor = cursor
|
||||
var index = nextLocators.lastIndex
|
||||
while (index >= 0) {
|
||||
if (!nextLocators[index].isValidJumpLocator(chapterCount)) {
|
||||
nextLocators.removeAt(index)
|
||||
if (nextCursor >= index) nextCursor--
|
||||
}
|
||||
index--
|
||||
}
|
||||
return copy(
|
||||
locators = nextLocators,
|
||||
cursor = nextCursor.coerceIn(-1, nextLocators.lastIndex)
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun stepBack(): ReaderJumpHistory {
|
||||
return if (backLocator == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun stepForward(): ReaderJumpHistory {
|
||||
return if (forwardLocator == null) this else copy(cursor = (cursor + 1).coerceAtMost(locators.lastIndex))
|
||||
}
|
||||
|
||||
fun clear(): ReaderJumpHistory = copy(locators = emptyList(), cursor = -1)
|
||||
|
||||
private fun bounded(): ReaderJumpHistory {
|
||||
val safeMaxEntries = maxEntries.coerceAtLeast(2)
|
||||
if (locators.size <= safeMaxEntries) {
|
||||
return copy(cursor = cursor.coerceIn(-1, locators.lastIndex))
|
||||
}
|
||||
val overflow = locators.size - safeMaxEntries
|
||||
return copy(
|
||||
locators = locators.drop(overflow),
|
||||
cursor = (cursor - overflow).coerceIn(-1, locators.size - overflow - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderLocator.hasSameJumpLocation(other: ReaderLocator): Boolean {
|
||||
return jumpLocationKey() == other.jumpLocationKey()
|
||||
}
|
||||
|
||||
private fun ReaderLocator.isValidJumpLocator(chapterCount: Int): Boolean {
|
||||
if (chapterCount <= 0) return false
|
||||
val chapter = chapterIndex
|
||||
return chapter == null || chapter in 0 until chapterCount
|
||||
}
|
||||
|
||||
private fun ReaderLocator.jumpLocationKey(): String {
|
||||
val stableCfi = cfi?.takeIf { it.isNotBlank() }
|
||||
if (stableCfi != null) {
|
||||
return listOf(
|
||||
chapterIndex?.toString().orEmpty(),
|
||||
chapterId.orEmpty(),
|
||||
href.orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
stableCfi
|
||||
).joinToString("|")
|
||||
}
|
||||
return listOf(
|
||||
chapterIndex?.toString().orEmpty(),
|
||||
chapterId.orEmpty(),
|
||||
href.orEmpty(),
|
||||
pageIndex?.toString().orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
cfi.orEmpty()
|
||||
).joinToString("|")
|
||||
}
|
||||
|
|
@ -11,7 +11,15 @@ data class SharedEpubBook(
|
|||
val title: String,
|
||||
val author: String? = null,
|
||||
val chapters: List<SharedEpubChapter>,
|
||||
val css: Map<String, String> = emptyMap()
|
||||
val css: Map<String, String> = emptyMap(),
|
||||
val tableOfContents: List<SharedEpubTocEntry> = emptyList()
|
||||
)
|
||||
|
||||
data class SharedEpubTocEntry(
|
||||
val label: String,
|
||||
val href: String,
|
||||
val fragmentId: String? = null,
|
||||
val depth: Int = 0
|
||||
)
|
||||
|
||||
data class SharedEpubChapter(
|
||||
|
|
@ -30,6 +38,11 @@ enum class ReaderReadingMode {
|
|||
VERTICAL
|
||||
}
|
||||
|
||||
enum class ReaderPageSpreadMode {
|
||||
SINGLE,
|
||||
TWO_PAGE
|
||||
}
|
||||
|
||||
enum class SharedReaderTextAlign {
|
||||
START,
|
||||
JUSTIFY,
|
||||
|
|
@ -41,7 +54,7 @@ data class ReaderSettings(
|
|||
val lineSpacing: Float = 1.45f,
|
||||
val margin: Int = 48,
|
||||
val darkMode: Boolean = false,
|
||||
val readingMode: ReaderReadingMode = ReaderReadingMode.PAGINATED,
|
||||
val readingMode: ReaderReadingMode = ReaderReadingMode.VERTICAL,
|
||||
val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START,
|
||||
val pageWidth: Int = 760,
|
||||
val fontFamily: String = "Default",
|
||||
|
|
@ -58,6 +71,9 @@ data class ReaderSettings(
|
|||
val systemUiMode: SystemUiMode = SystemUiMode.DEFAULT,
|
||||
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
|
||||
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
|
||||
val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE,
|
||||
val pdfVerticalPageGapVisible: Boolean = true,
|
||||
val pdfPageNumberOverlayVisible: Boolean = true,
|
||||
val seamlessChapterNavigation: Boolean = true,
|
||||
val chapterTurnDragMultiplier: Float = 1.0f
|
||||
) {
|
||||
|
|
@ -65,13 +81,73 @@ data class ReaderSettings(
|
|||
val resolvedVerticalMargin: Int get() = verticalMargin ?: margin
|
||||
}
|
||||
|
||||
data class ReaderLayoutSignature(
|
||||
val fontSize: Int,
|
||||
val lineSpacing: Float,
|
||||
val horizontalMargin: Int,
|
||||
val verticalMargin: Int,
|
||||
val readingMode: ReaderReadingMode,
|
||||
val textAlign: SharedReaderTextAlign,
|
||||
val pageWidth: Int,
|
||||
val fontFamily: String,
|
||||
val paragraphSpacing: Float,
|
||||
val imageScale: Float,
|
||||
val pageSpreadMode: ReaderPageSpreadMode,
|
||||
val customFontPath: String?
|
||||
)
|
||||
|
||||
data class ReaderAppearanceSignature(
|
||||
val darkMode: Boolean,
|
||||
val themeId: String?,
|
||||
val textureId: String?,
|
||||
val textureAlpha: Float,
|
||||
val backgroundColorArgb: Long?,
|
||||
val textColorArgb: Long?
|
||||
)
|
||||
|
||||
fun ReaderSettings.layoutSignature(): ReaderLayoutSignature {
|
||||
return ReaderLayoutSignature(
|
||||
fontSize = fontSize,
|
||||
lineSpacing = lineSpacing,
|
||||
horizontalMargin = resolvedHorizontalMargin,
|
||||
verticalMargin = resolvedVerticalMargin,
|
||||
readingMode = readingMode,
|
||||
textAlign = textAlign,
|
||||
pageWidth = pageWidth,
|
||||
fontFamily = fontFamily,
|
||||
paragraphSpacing = paragraphSpacing,
|
||||
imageScale = imageScale,
|
||||
pageSpreadMode = pageSpreadMode,
|
||||
customFontPath = customFontPath
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.appearanceSignature(): ReaderAppearanceSignature {
|
||||
return ReaderAppearanceSignature(
|
||||
darkMode = darkMode,
|
||||
themeId = themeId,
|
||||
textureId = textureId,
|
||||
textureAlpha = textureAlpha,
|
||||
backgroundColorArgb = backgroundColorArgb,
|
||||
textColorArgb = textColorArgb
|
||||
)
|
||||
}
|
||||
|
||||
data class ReaderViewportSpec(
|
||||
val widthPx: Int,
|
||||
val heightPx: Int
|
||||
) {
|
||||
val isSpecified: Boolean get() = widthPx > 0 && heightPx > 0
|
||||
}
|
||||
|
||||
data class ReaderPage(
|
||||
val pageIndex: Int,
|
||||
val chapterIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val text: String,
|
||||
val startOffset: Int,
|
||||
val endOffset: Int
|
||||
val endOffset: Int,
|
||||
val semanticBlocks: List<SemanticBlock> = emptyList()
|
||||
)
|
||||
|
||||
data class PaginatedReaderState(
|
||||
|
|
@ -81,40 +157,96 @@ data class PaginatedReaderState(
|
|||
val settings: ReaderSettings = ReaderSettings()
|
||||
) {
|
||||
val currentPage: ReaderPage? get() = pages.getOrNull(currentPageIndex)
|
||||
val progress: Float get() = if (pages.isEmpty()) 0f else ((currentPageIndex + 1).toFloat() / pages.size) * 100f
|
||||
val progress: Float
|
||||
get() {
|
||||
if (pages.isEmpty()) return 0f
|
||||
val visibleEnd = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings)
|
||||
.lastOrNull()
|
||||
?: currentPageIndex
|
||||
return ((visibleEnd + 1).toFloat() / pages.size) * 100f
|
||||
}
|
||||
val canGoPrevious: Boolean get() = currentPageIndex > 0
|
||||
val canGoNext: Boolean get() = currentPageIndex < pages.lastIndex
|
||||
val canGoNext: Boolean get() = ReaderSpreadLayout.canGoNext(currentPageIndex, pages.size, settings)
|
||||
val currentSpreadStartIndex: Int get() = ReaderSpreadLayout.normalizePageIndex(currentPageIndex, pages.size, settings)
|
||||
val visiblePages: List<ReaderPage>
|
||||
get() = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings)
|
||||
.mapNotNull { pages.getOrNull(it) }
|
||||
}
|
||||
|
||||
object SampleReaderBooks {
|
||||
fun desktopWelcomeBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "desktop_welcome",
|
||||
fileName = "Desktop Welcome.epub",
|
||||
title = "Episteme Desktop Reader",
|
||||
author = "Episteme",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "intro",
|
||||
title = "A Careful First Page",
|
||||
plainText = """
|
||||
This is the first desktop paginated reader milestone.
|
||||
object ReaderSpreadLayout {
|
||||
fun pageStep(settings: ReaderSettings): Int {
|
||||
return if (settings.isTwoPageSpreadEnabled()) 2 else 1
|
||||
}
|
||||
|
||||
It intentionally starts with the quiet parts: page state, chapter navigation, font sizing, margins, light and dark reading surfaces, progress, and a JVM EPUB loader. The Android reader remains where it is, which keeps the mobile app protected while Windows grows its own platform layer.
|
||||
fun normalizePageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
if (pageCount <= 0) return 0
|
||||
val clamped = pageIndex.coerceIn(0, pageCount - 1)
|
||||
return if (settings.isTwoPageSpreadEnabled()) {
|
||||
(clamped - (clamped % 2)).coerceIn(0, pageCount - 1)
|
||||
} else {
|
||||
clamped
|
||||
}
|
||||
}
|
||||
|
||||
The next pieces can be added one by one: persisted locations, bookmarks, highlights, table of contents polish, keyboard shortcuts, and eventually the richer pagination engine from Android once its platform-specific parts are behind interfaces.
|
||||
""".trimIndent()
|
||||
),
|
||||
SharedEpubChapter(
|
||||
id = "scope",
|
||||
title = "What Works Here",
|
||||
plainText = """
|
||||
The desktop shell can import EPUB files and extract readable spine text using the JDK zip APIs. It does not try to render complex CSS, images, MathML, or annotations yet.
|
||||
fun canGoNext(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Boolean {
|
||||
if (pageCount <= 1) return false
|
||||
val current = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
return current + pageStep(settings) < pageCount
|
||||
}
|
||||
|
||||
That limitation is deliberate. A plain paginated reader gives us a working Windows loop without pulling Android WebView, SAF, Room, PDF, or existing reader screens into the first KMP step.
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
)
|
||||
fun nextPageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
return normalizePageIndex(pageIndex + pageStep(settings), pageCount, settings)
|
||||
}
|
||||
|
||||
fun previousPageIndex(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
return normalizePageIndex(pageIndex - pageStep(settings), pageCount, settings)
|
||||
}
|
||||
|
||||
fun visiblePageIndices(pageIndex: Int, pageCount: Int, settings: ReaderSettings): List<Int> {
|
||||
if (pageCount <= 0) return emptyList()
|
||||
val start = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
if (!settings.isTwoPageSpreadEnabled()) return listOf(start)
|
||||
return listOf(start, start + 1).filter { it in 0 until pageCount }
|
||||
}
|
||||
|
||||
fun pageRangeLabel(pageIndex: Int, pageCount: Int, settings: ReaderSettings): String {
|
||||
val total = pageCount.coerceAtLeast(1)
|
||||
val pages = visiblePageIndices(pageIndex, total, settings).ifEmpty { listOf(0) }
|
||||
val first = pages.first() + 1
|
||||
val last = pages.last() + 1
|
||||
return if (first == last) "$first" else "$first-$last"
|
||||
}
|
||||
|
||||
fun sliderStepCount(pageCount: Int, settings: ReaderSettings): Int {
|
||||
val total = pageCount.coerceAtLeast(1)
|
||||
return if (settings.isTwoPageSpreadEnabled()) {
|
||||
(total + 1) / 2
|
||||
} else {
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
fun sliderPositionForPage(pageIndex: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
val normalized = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
val position = if (settings.isTwoPageSpreadEnabled()) {
|
||||
(normalized / 2) + 1
|
||||
} else {
|
||||
normalized + 1
|
||||
}
|
||||
return position.coerceIn(1, sliderStepCount(pageCount, settings))
|
||||
}
|
||||
|
||||
fun pageNumberForSliderPosition(position: Int, pageCount: Int, settings: ReaderSettings): Int {
|
||||
val clamped = position.coerceIn(1, sliderStepCount(pageCount, settings))
|
||||
val pageIndex = if (settings.isTwoPageSpreadEnabled()) {
|
||||
(clamped - 1) * 2
|
||||
} else {
|
||||
clamped - 1
|
||||
}
|
||||
return normalizePageIndex(pageIndex, pageCount, settings) + 1
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderSettings.isTwoPageSpreadEnabled(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED && pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
internal const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
|
||||
internal const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
|
||||
|
||||
internal expect val SharedReaderDiagnosticsEnabled: Boolean
|
||||
internal expect fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean
|
||||
|
||||
internal inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
|
||||
if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
|
||||
println("$tag ${message()}")
|
||||
}
|
||||
}
|
||||
|
|
@ -7,5 +7,5 @@ import androidx.compose.ui.Modifier
|
|||
internal expect fun LocalBookCoverImage(
|
||||
path: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import com.aryan.reader.shared.BookItem
|
|||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.LibraryFilters
|
||||
import com.aryan.reader.shared.ReadStatusFilter
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.ShelfType
|
||||
import com.aryan.reader.shared.isOpdsStream
|
||||
|
|
@ -26,43 +29,48 @@ enum class SharedAppToolAction {
|
|||
data class SharedAppShellModel(
|
||||
val primaryTabs: List<SharedAppTab>,
|
||||
val selectedPrimaryTab: SharedAppTab,
|
||||
val toolActions: List<SharedAppToolAction>
|
||||
val toolActions: List<SharedAppToolAction>,
|
||||
val showPrimaryNavigation: Boolean
|
||||
)
|
||||
|
||||
fun sharedAppShellModel(
|
||||
selectedTab: SharedAppTab,
|
||||
aiSettingsAvailable: Boolean
|
||||
aiSettingsAvailable: Boolean,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
|
||||
): SharedAppShellModel {
|
||||
val primaryTabs = listOf(
|
||||
SharedAppTab.HOME,
|
||||
SharedAppTab.LIBRARY,
|
||||
SharedAppTab.CATALOGS,
|
||||
SharedAppTab.READER
|
||||
)
|
||||
val primaryTabs = buildList {
|
||||
add(SharedAppTab.HOME)
|
||||
add(SharedAppTab.LIBRARY)
|
||||
if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS)
|
||||
}
|
||||
val selectedPrimaryTab = when (selectedTab) {
|
||||
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SETTINGS,
|
||||
SharedAppTab.CUSTOM_FONTS,
|
||||
SharedAppTab.SUPPORT,
|
||||
SharedAppTab.FEEDBACK,
|
||||
SharedAppTab.ABOUT -> SharedAppTab.HOME
|
||||
else -> selectedTab
|
||||
}
|
||||
}.takeIf { it in primaryTabs } ?: SharedAppTab.HOME
|
||||
val toolActions = buildList {
|
||||
add(SharedAppToolAction.IMPORT_FILES)
|
||||
add(SharedAppToolAction.IMPORT_FOLDER)
|
||||
add(SharedAppToolAction.SYNC)
|
||||
add(SharedAppToolAction.APP_THEME)
|
||||
if (aiSettingsAvailable) add(SharedAppToolAction.AI_SETTINGS)
|
||||
if (aiSettingsAvailable && featurePolicy.aiAndCloud) add(SharedAppToolAction.AI_SETTINGS)
|
||||
add(SharedAppToolAction.CUSTOM_FONTS)
|
||||
add(SharedAppToolAction.HELP_FEEDBACK)
|
||||
add(SharedAppToolAction.SUPPORT)
|
||||
if (featurePolicy.projectLinks) {
|
||||
add(SharedAppToolAction.HELP_FEEDBACK)
|
||||
add(SharedAppToolAction.SUPPORT)
|
||||
}
|
||||
add(SharedAppToolAction.ABOUT)
|
||||
add(SharedAppToolAction.TABS_TOGGLE)
|
||||
}
|
||||
return SharedAppShellModel(
|
||||
primaryTabs = primaryTabs,
|
||||
selectedPrimaryTab = selectedPrimaryTab,
|
||||
toolActions = toolActions
|
||||
toolActions = toolActions,
|
||||
showPrimaryNavigation = selectedTab != SharedAppTab.READER
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +123,50 @@ data class NonReaderLibraryOrganizationModel(
|
|||
val hasOpdsStreams: Boolean
|
||||
)
|
||||
|
||||
internal data class NonReaderLibraryFileTypeGroup(
|
||||
val title: String,
|
||||
val fileTypes: List<FileType>
|
||||
)
|
||||
|
||||
private val LibraryFileTypeGroupTemplates = listOf(
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Books",
|
||||
fileTypes = listOf(FileType.EPUB, FileType.MOBI, FileType.FB2)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Documents",
|
||||
fileTypes = listOf(FileType.PDF, FileType.PPTX, FileType.DOCX, FileType.ODT, FileType.FODT)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Text and web",
|
||||
fileTypes = listOf(FileType.MD, FileType.TXT, FileType.HTML)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Comics",
|
||||
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
)
|
||||
)
|
||||
|
||||
internal fun nonReaderLibraryFileTypeGroups(
|
||||
platform: ReaderPlatform = ReaderPlatform.DESKTOP
|
||||
): List<NonReaderLibraryFileTypeGroup> {
|
||||
val readableTypes = SharedFileCapabilities.readableTypesFor(platform)
|
||||
val knownGroupedTypes = LibraryFileTypeGroupTemplates.flatMapTo(mutableSetOf()) { it.fileTypes }
|
||||
val grouped = LibraryFileTypeGroupTemplates.mapNotNull { group ->
|
||||
val visibleTypes = group.fileTypes.filter { it in readableTypes }
|
||||
group.copy(fileTypes = visibleTypes).takeIf { visibleTypes.isNotEmpty() }
|
||||
}
|
||||
val otherTypes = readableTypes
|
||||
.filterNot { it in knownGroupedTypes }
|
||||
.sortedBy { it.ordinal }
|
||||
|
||||
return if (otherTypes.isEmpty()) {
|
||||
grouped
|
||||
} else {
|
||||
grouped + NonReaderLibraryFileTypeGroup("Other", otherTypes)
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
|
||||
val books = rawLibraryBooks
|
||||
val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,48 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderHighlightPalette
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSearchOptions
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
data class ReaderContentNavigationTarget(
|
||||
val locator: ReaderLocator?,
|
||||
val requestId: Long,
|
||||
val readingMode: ReaderReadingMode,
|
||||
val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(),
|
||||
val ttsLocator: ReaderLocator? = null,
|
||||
val ttsRequestId: Long = 0L
|
||||
)
|
||||
|
||||
sealed interface ReaderContentRenderPlan {
|
||||
val background: Color
|
||||
val foreground: Color
|
||||
val navigationTarget: ReaderContentNavigationTarget
|
||||
val highlights: List<UserHighlight>
|
||||
|
||||
data class WebDocument(
|
||||
val html: String,
|
||||
val appearanceScript: String,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
override val highlights: List<UserHighlight>
|
||||
) : ReaderContentRenderPlan
|
||||
|
||||
data class NativePaginatedPages(
|
||||
val visiblePages: List<ReaderPage>,
|
||||
val settings: ReaderSettings,
|
||||
val searchQuery: String,
|
||||
val searchOptions: ReaderSearchOptions,
|
||||
val highlightPalette: ReaderHighlightPalette,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
override val highlights: List<UserHighlight>
|
||||
) : ReaderContentRenderPlan
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ReaderMinimalSlider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onValueChangeStarted: (() -> Unit)? = null,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
activeColor: Color? = null,
|
||||
inactiveColor: Color? = null,
|
||||
thumbColor: Color? = null
|
||||
) {
|
||||
var widthPx by remember { mutableFloatStateOf(0f) }
|
||||
val rangeStart = valueRange.start
|
||||
val rangeEnd = valueRange.endInclusive
|
||||
|
||||
fun valueForOffset(offsetX: Float): Float {
|
||||
if (widthPx <= 0f || rangeEnd <= rangeStart) return value.coerceIn(rangeStart, rangeEnd)
|
||||
val fraction = (offsetX / widthPx).coerceIn(0f, 1f)
|
||||
return rangeStart + (rangeEnd - rangeStart) * fraction
|
||||
}
|
||||
|
||||
val inputModifier = if (enabled) {
|
||||
Modifier.pointerInput(rangeStart, rangeEnd, widthPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
onValueChangeStarted?.invoke()
|
||||
onValueChange(valueForOffset(down.position.x))
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
onValueChange(valueForOffset(change.position.x))
|
||||
change.consume()
|
||||
}
|
||||
|
||||
onValueChangeFinished?.invoke()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val effectiveActiveColor = activeColor ?: MaterialTheme.colorScheme.primary
|
||||
val effectiveInactiveColor = inactiveColor ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
val effectiveThumbColor = thumbColor ?: MaterialTheme.colorScheme.primary
|
||||
val disabledAlpha = if (enabled) 1f else 0.38f
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(24.dp)
|
||||
.onSizeChanged { widthPx = it.width.toFloat() }
|
||||
.then(inputModifier)
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val range = rangeEnd - rangeStart
|
||||
val fraction = if (range > 0f) {
|
||||
((value.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val trackHeight = 4.dp.toPx()
|
||||
val thumbRadius = 7.dp.toPx()
|
||||
val centerY = size.height / 2f
|
||||
val cornerRadius = CornerRadius(trackHeight / 2f, trackHeight / 2f)
|
||||
val activeWidth = size.width * fraction
|
||||
|
||||
drawRoundRect(
|
||||
color = effectiveInactiveColor.copy(alpha = effectiveInactiveColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(0f, centerY - trackHeight / 2f),
|
||||
size = Size(size.width, trackHeight),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
drawRoundRect(
|
||||
color = effectiveActiveColor.copy(alpha = effectiveActiveColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(0f, centerY - trackHeight / 2f),
|
||||
size = Size(activeWidth, trackHeight),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
|
||||
val thumbCenterX = if (size.width <= thumbRadius * 2f) {
|
||||
size.width / 2f
|
||||
} else {
|
||||
activeWidth.coerceIn(thumbRadius, size.width - thumbRadius)
|
||||
}
|
||||
drawCircle(
|
||||
color = effectiveThumbColor.copy(alpha = effectiveThumbColor.alpha * disabledAlpha),
|
||||
radius = thumbRadius,
|
||||
center = Offset(thumbCenterX, centerY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,8 @@ enum class ReaderWorkspaceLeftSection(val title: String) {
|
|||
CONTENTS("Contents"),
|
||||
SEARCH("Search"),
|
||||
BOOKMARKS("Bookmarks"),
|
||||
NOTES("Notes")
|
||||
NOTES("Annotations"),
|
||||
PAGES("Pages")
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceInspectorSection(val title: String) {
|
||||
|
|
@ -32,6 +33,7 @@ enum class ReaderWorkspaceTopAction {
|
|||
CONTENTS,
|
||||
SEARCH,
|
||||
BOOKMARK,
|
||||
FULL_SCREEN,
|
||||
APPEARANCE,
|
||||
READ_ALOUD,
|
||||
AI,
|
||||
|
|
@ -51,6 +53,11 @@ data class ReaderWorkspaceChromeModel(
|
|||
val forceVisibleReasons: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
data class ReaderWorkspacePanelDefaults(
|
||||
val leftOpen: Boolean = false,
|
||||
val inspectorOpen: Boolean = false
|
||||
)
|
||||
|
||||
data class ReaderWorkspaceModel(
|
||||
val kind: ReaderWorkspaceKind,
|
||||
val leftSections: List<ReaderWorkspaceLeftSection>,
|
||||
|
|
@ -58,6 +65,7 @@ data class ReaderWorkspaceModel(
|
|||
val topActions: List<ReaderWorkspaceTopAction>,
|
||||
val bottomActions: List<ReaderWorkspaceBottomAction>,
|
||||
val defaultPdfInteractionMode: PdfInkTool? = null,
|
||||
val panelDefaults: ReaderWorkspacePanelDefaults = ReaderWorkspacePanelDefaults(),
|
||||
val chrome: ReaderWorkspaceChromeModel
|
||||
)
|
||||
|
||||
|
|
@ -65,38 +73,38 @@ fun epubReaderWorkspaceModel(
|
|||
session: ReaderSessionState,
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
): ReaderWorkspaceModel {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
val leftSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.TOC)) add(ReaderWorkspaceLeftSection.CONTENTS)
|
||||
if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceLeftSection.SEARCH)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceLeftSection.NOTES)
|
||||
}
|
||||
val leftSections = listOf(
|
||||
ReaderWorkspaceLeftSection.CONTENTS,
|
||||
ReaderWorkspaceLeftSection.NOTES,
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS
|
||||
)
|
||||
val inspectorSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) {
|
||||
add(ReaderWorkspaceInspectorSection.APPEARANCE)
|
||||
}
|
||||
if (preferences.isVisible(ReaderTool.READING_MODE) || preferences.isVisible(ReaderTool.VISUAL_OPTIONS)) {
|
||||
if (preferences.isVisible(ReaderTool.READING_MODE)) {
|
||||
add(ReaderWorkspaceInspectorSection.TOOLS)
|
||||
}
|
||||
if (
|
||||
preferences.isVisible(ReaderTool.DICTIONARY) ||
|
||||
preferences.isVisible(ReaderTool.AI_FEATURES) ||
|
||||
preferences.isVisible(ReaderTool.TTS_CONTROLS) ||
|
||||
(aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) ||
|
||||
(cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) ||
|
||||
preferences.isVisible(ReaderTool.AUTO_SCROLL)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.AI_TTS)
|
||||
}
|
||||
add(ReaderWorkspaceInspectorSection.TOOLBAR)
|
||||
}.distinct()
|
||||
val topActions = buildList {
|
||||
if (ReaderWorkspaceLeftSection.CONTENTS in leftSections) add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
if (preferences.isVisible(ReaderTool.SEARCH)) add(ReaderWorkspaceTopAction.SEARCH)
|
||||
if (preferences.isVisible(ReaderTool.BOOKMARK)) add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
add(ReaderWorkspaceTopAction.FULL_SCREEN)
|
||||
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI)
|
||||
if (preferences.isVisible(ReaderTool.AUTO_SCROLL)) add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS)
|
||||
|
|
@ -113,7 +121,7 @@ fun epubReaderWorkspaceModel(
|
|||
topActions = topActions,
|
||||
bottomActions = bottomActions,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
preferAutoHide = false,
|
||||
searchActive = session.isSearchActive,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
|
|
@ -130,14 +138,18 @@ fun epubReaderWorkspaceModel(
|
|||
fun readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
bottom: Boolean,
|
||||
aiAvailable: Boolean
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
): List<ReaderTool> {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
return preferences.orderedVisibleTools()
|
||||
.filter { tool ->
|
||||
tool.supportsDesktopQuickAction &&
|
||||
preferences.isBottom(tool) == bottom &&
|
||||
(tool != ReaderTool.AI_FEATURES || aiAvailable)
|
||||
(tool != ReaderTool.AI_FEATURES || aiAvailable) &&
|
||||
(tool != ReaderTool.TTS_CONTROLS || cloudTtsAvailable) &&
|
||||
(tool != ReaderTool.DICTIONARY || externalLookupAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,13 +166,15 @@ fun pdfReaderWorkspaceModel(
|
|||
loading: Boolean,
|
||||
errorMessage: String?,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
): ReaderWorkspaceModel {
|
||||
val leftSections = buildList {
|
||||
add(ReaderWorkspaceLeftSection.CONTENTS)
|
||||
add(ReaderWorkspaceLeftSection.SEARCH)
|
||||
if (hasBookmarks) add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
if (hasContents || hasAnnotations || hasEmbeddedComments) add(ReaderWorkspaceLeftSection.NOTES)
|
||||
add(ReaderWorkspaceLeftSection.NOTES)
|
||||
add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
add(ReaderWorkspaceLeftSection.PAGES)
|
||||
}.distinct()
|
||||
val inspectorSections = listOf(
|
||||
ReaderWorkspaceInspectorSection.APPEARANCE,
|
||||
|
|
@ -172,8 +186,9 @@ fun pdfReaderWorkspaceModel(
|
|||
add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
add(ReaderWorkspaceTopAction.SEARCH)
|
||||
add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
add(ReaderWorkspaceTopAction.FULL_SCREEN)
|
||||
add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (cloudTtsAvailable) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
|
||||
add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
add(ReaderWorkspaceTopAction.TOOLS)
|
||||
|
|
@ -190,11 +205,11 @@ fun pdfReaderWorkspaceModel(
|
|||
),
|
||||
defaultPdfInteractionMode = null,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
preferAutoHide = false,
|
||||
searchActive = searchActive || state.searchQuery.isNotBlank(),
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.PEN,
|
||||
annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.NONE,
|
||||
richTextEditing = richTextEditing,
|
||||
loading = loading,
|
||||
errorMessage = errorMessage,
|
||||
|
|
|
|||
|
|
@ -1,44 +1,59 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.BookmarkBorder
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.BannerMessage
|
||||
import com.aryan.reader.shared.reader.logSharedReaderDiagnostic
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ReaderWorkspaceShell(
|
||||
|
|
@ -47,22 +62,46 @@ fun ReaderWorkspaceShell(
|
|||
subtitle: String,
|
||||
progressLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
topActions: @Composable RowScope.() -> Unit = {},
|
||||
leftSidebar: @Composable () -> Unit,
|
||||
onReturnToLibrary: (() -> Unit)? = null,
|
||||
isFullscreen: Boolean = false,
|
||||
onFullscreenChange: ((Boolean) -> Unit)? = null,
|
||||
fullscreenExitMessage: String = "Esc to exit",
|
||||
isBookmarked: Boolean = false,
|
||||
onToggleBookmark: (() -> Unit)? = null,
|
||||
onSearchAction: (() -> Unit)? = null,
|
||||
topSearchBar: (@Composable () -> Unit)? = null,
|
||||
leftSidebar: @Composable (closePanel: () -> Unit) -> Unit,
|
||||
rightInspector: @Composable () -> Unit,
|
||||
bottomBar: @Composable () -> Unit,
|
||||
fullscreenBottomBar: (@Composable () -> Unit)? = null,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
var leftPanelOpen by remember(model.kind) { mutableStateOf(true) }
|
||||
var rightPanelOpen by remember(model.kind) { mutableStateOf(true) }
|
||||
var chromeVisible by remember(model.kind) { mutableStateOf(true) }
|
||||
val forceChrome = model.chrome.forceVisible || leftPanelOpen || rightPanelOpen
|
||||
var leftPanelOpen by remember(model.kind, model.panelDefaults.leftOpen) {
|
||||
mutableStateOf(model.panelDefaults.leftOpen)
|
||||
}
|
||||
var rightPanelOpen by remember(model.kind, model.panelDefaults.inspectorOpen) {
|
||||
mutableStateOf(model.panelDefaults.inspectorOpen)
|
||||
}
|
||||
var modalAnchorBounds by remember { mutableStateOf<SharedReaderModalAnchorBounds?>(null) }
|
||||
var fullscreenBannerVisible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(forceChrome, model.chrome.preferAutoHide, model.chrome.forceVisibleReasons) {
|
||||
chromeVisible = true
|
||||
if (model.chrome.preferAutoHide && !forceChrome) {
|
||||
delay(3_200)
|
||||
chromeVisible = false
|
||||
LaunchedEffect(isFullscreen) {
|
||||
if (isFullscreen) {
|
||||
fullscreenBannerVisible = true
|
||||
delay(2_600)
|
||||
fullscreenBannerVisible = false
|
||||
} else {
|
||||
fullscreenBannerVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(model.kind, model.chrome.forceVisibleReasons) {
|
||||
val reasons = model.chrome.forceVisibleReasons
|
||||
if (reasons.any { it == "search" }) {
|
||||
leftPanelOpen = false
|
||||
rightPanelOpen = false
|
||||
} else if (reasons.any { it == "rich-text" } && model.inspectorSections.isNotEmpty()) {
|
||||
rightPanelOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,82 +109,207 @@ fun ReaderWorkspaceShell(
|
|||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
val wide = maxWidth >= 1120.dp
|
||||
val showChrome = chromeVisible || forceChrome || !model.chrome.preferAutoHide
|
||||
) shellConstraints@ {
|
||||
val wide = this@shellConstraints.maxWidth >= 1120.dp
|
||||
LaunchedEffect(wide, leftPanelOpen, rightPanelOpen) {
|
||||
if (!wide && leftPanelOpen && rightPanelOpen) {
|
||||
rightPanelOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (showChrome) {
|
||||
ReaderWorkspaceTopChrome(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
progressLabel = progressLabel,
|
||||
wide = wide,
|
||||
leftPanelOpen = leftPanelOpen,
|
||||
rightPanelOpen = rightPanelOpen,
|
||||
onToggleLeftPanel = { leftPanelOpen = !leftPanelOpen },
|
||||
onToggleRightPanel = { rightPanelOpen = !rightPanelOpen },
|
||||
topActions = topActions
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (wide && leftPanelOpen && model.leftSections.isNotEmpty()) {
|
||||
leftSidebar()
|
||||
}
|
||||
CompositionLocalProvider(LocalSharedReaderModalAnchorBounds provides modalAnchorBounds) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = if (isFullscreen) 0.dp else 8.dp,
|
||||
top = if (isFullscreen) 0.dp else 8.dp,
|
||||
end = if (isFullscreen) 0.dp else 8.dp
|
||||
)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
logReaderGapLayout(
|
||||
layer = "shell_column",
|
||||
bounds = coordinates.boundsInWindow(),
|
||||
details = if (isFullscreen) {
|
||||
"fullscreen=true padding=0 verticalGap=0"
|
||||
} else {
|
||||
"fullscreen=false padding=start8 top8 end8 bottom0 verticalGap=6"
|
||||
}
|
||||
)
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(if (isFullscreen) 0.dp else 6.dp)
|
||||
) {
|
||||
if (!isFullscreen || topSearchBar != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
logReaderGapLayout("top_chrome_slot", coordinates.boundsInWindow())
|
||||
}
|
||||
) {
|
||||
if (topSearchBar != null) {
|
||||
topSearchBar()
|
||||
} else {
|
||||
ReaderWorkspaceTopChrome(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
progressLabel = progressLabel,
|
||||
topActions = model.topActions,
|
||||
hasLeftPanel = model.leftSections.isNotEmpty(),
|
||||
hasRightPanel = model.inspectorSections.isNotEmpty(),
|
||||
leftPanelOpen = leftPanelOpen,
|
||||
rightPanelOpen = rightPanelOpen,
|
||||
isBookmarked = isBookmarked,
|
||||
onReturnToLibrary = onReturnToLibrary,
|
||||
onToggleLeftPanel = { leftPanelOpen = !leftPanelOpen },
|
||||
onToggleRightPanel = { rightPanelOpen = !rightPanelOpen },
|
||||
onToggleBookmark = onToggleBookmark,
|
||||
onSearchAction = onSearchAction,
|
||||
onEnterFullscreen = onFullscreenChange?.let { change -> { change(true) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
logReaderGapLayout("content_slot", coordinates.boundsInWindow())
|
||||
}
|
||||
) {
|
||||
val showLeftPanel = !isFullscreen && leftPanelOpen && model.leftSections.isNotEmpty()
|
||||
val showRightPanel = !isFullscreen && rightPanelOpen && model.inspectorSections.isNotEmpty()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clipToBounds()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val bounds = coordinates.boundsInWindow()
|
||||
val nextBounds = SharedReaderModalAnchorBounds(
|
||||
leftPx = bounds.left,
|
||||
topPx = bounds.top,
|
||||
widthPx = bounds.width,
|
||||
heightPx = bounds.height
|
||||
)
|
||||
if (modalAnchorBounds != nextBounds) {
|
||||
modalAnchorBounds = nextBounds
|
||||
}
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
if (wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) {
|
||||
rightInspector()
|
||||
}
|
||||
ReaderWorkspacePanelOverlays(
|
||||
showLeftPanel = showLeftPanel,
|
||||
showRightPanel = showRightPanel,
|
||||
wide = wide,
|
||||
onCloseLeftPanel = { leftPanelOpen = false },
|
||||
onCloseRightPanel = { rightPanelOpen = false },
|
||||
leftSidebar = leftSidebar,
|
||||
rightInspector = rightInspector
|
||||
)
|
||||
}
|
||||
|
||||
if (!wide && leftPanelOpen && model.leftSections.isNotEmpty()) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Reader",
|
||||
onClose = { leftPanelOpen = false },
|
||||
modifier = Modifier.align(Alignment.CenterStart).width(320.dp)
|
||||
) {
|
||||
leftSidebar()
|
||||
}
|
||||
}
|
||||
if (!wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Tools",
|
||||
onClose = { rightPanelOpen = false },
|
||||
modifier = Modifier.align(Alignment.CenterEnd).width(360.dp)
|
||||
) {
|
||||
rightInspector()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
logReaderGapLayout("bottom_bar_slot", coordinates.boundsInWindow())
|
||||
}
|
||||
) {
|
||||
key(isFullscreen) {
|
||||
val immersiveBottomBar = fullscreenBottomBar
|
||||
if (isFullscreen && immersiveBottomBar != null) {
|
||||
immersiveBottomBar()
|
||||
} else {
|
||||
bottomBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showChrome) {
|
||||
bottomBar()
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(20.dp)
|
||||
.clickable { chromeVisible = true }
|
||||
)
|
||||
ReaderWorkspaceTopBanner(
|
||||
bannerMessage = if (fullscreenBannerVisible) BannerMessage(fullscreenExitMessage) else null,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderWorkspacePanelOverlays(
|
||||
showLeftPanel: Boolean,
|
||||
showRightPanel: Boolean,
|
||||
wide: Boolean,
|
||||
onCloseLeftPanel: () -> Unit,
|
||||
onCloseRightPanel: () -> Unit,
|
||||
leftSidebar: @Composable (closePanel: () -> Unit) -> Unit,
|
||||
rightInspector: @Composable () -> Unit
|
||||
) {
|
||||
if (!showLeftPanel && !showRightPanel) return
|
||||
|
||||
SharedReaderModalLayer(
|
||||
level = SharedReaderModalLevel.Panel,
|
||||
onDismiss = {
|
||||
if (showLeftPanel) onCloseLeftPanel()
|
||||
if (showRightPanel) onCloseRightPanel()
|
||||
}
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) panelConstraints@ {
|
||||
val availableWidth = this@panelConstraints.maxWidth
|
||||
val leftPanelWidth = if (wide) 340.dp else minOf(320.dp, availableWidth * 0.92f)
|
||||
val rightPanelWidth = if (wide) 380.dp else minOf(360.dp, availableWidth * 0.92f)
|
||||
if (showLeftPanel) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Reader",
|
||||
onClose = onCloseLeftPanel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.width(leftPanelWidth)
|
||||
) {
|
||||
leftSidebar(onCloseLeftPanel)
|
||||
}
|
||||
}
|
||||
if (showRightPanel) {
|
||||
ReaderWorkspaceOverlayPanel(
|
||||
title = "Tools",
|
||||
onClose = onCloseRightPanel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.width(rightPanelWidth)
|
||||
) {
|
||||
rightInspector()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ReaderGapLogTag = "EpistemeReaderGap"
|
||||
|
||||
private fun logReaderGapLayout(
|
||||
layer: String,
|
||||
bounds: Rect,
|
||||
details: String = ""
|
||||
) {
|
||||
logSharedReaderDiagnostic(ReaderGapLogTag) {
|
||||
buildString {
|
||||
append("compose_shell layer=")
|
||||
append(layer)
|
||||
append(" x=")
|
||||
append(bounds.left.roundToInt())
|
||||
append(" y=")
|
||||
append(bounds.top.roundToInt())
|
||||
append(" w=")
|
||||
append(bounds.width.roundToInt())
|
||||
append(" h=")
|
||||
append(bounds.height.roundToInt())
|
||||
append(" bottom=")
|
||||
append(bounds.bottom.roundToInt())
|
||||
if (details.isNotBlank()) {
|
||||
append(' ')
|
||||
append(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,43 +320,109 @@ private fun ReaderWorkspaceTopChrome(
|
|||
title: String,
|
||||
subtitle: String,
|
||||
progressLabel: String,
|
||||
wide: Boolean,
|
||||
topActions: List<ReaderWorkspaceTopAction>,
|
||||
hasLeftPanel: Boolean,
|
||||
hasRightPanel: Boolean,
|
||||
leftPanelOpen: Boolean,
|
||||
rightPanelOpen: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
onReturnToLibrary: (() -> Unit)?,
|
||||
onToggleLeftPanel: () -> Unit,
|
||||
onToggleRightPanel: () -> Unit,
|
||||
topActions: @Composable RowScope.() -> Unit
|
||||
onToggleBookmark: (() -> Unit)?,
|
||||
onSearchAction: (() -> Unit)?,
|
||||
onEnterFullscreen: (() -> Unit)?
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 2.dp
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
IconButton(onClick = onToggleLeftPanel) {
|
||||
Icon(Icons.Default.Menu, contentDescription = if (leftPanelOpen) "Hide reader navigation" else "Show reader navigation")
|
||||
onReturnToLibrary?.let { returnToLibrary ->
|
||||
IconButton(onClick = returnToLibrary, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library")
|
||||
}
|
||||
}
|
||||
if (hasLeftPanel) {
|
||||
IconButton(onClick = onToggleLeftPanel, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Menu, contentDescription = if (leftPanelOpen) "Hide reader navigation" else "Show reader navigation")
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(progressLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
topActions()
|
||||
}
|
||||
IconButton(onClick = onToggleRightPanel) {
|
||||
Icon(Icons.Default.Tune, contentDescription = if (rightPanelOpen) "Hide reader tools" else "Show reader tools")
|
||||
}
|
||||
if (!wide) {
|
||||
TextButton(onClick = onToggleRightPanel, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
||||
Text("Tools")
|
||||
if (ReaderWorkspaceTopAction.SEARCH in topActions && onSearchAction != null) {
|
||||
IconButton(onClick = onSearchAction, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search in reader")
|
||||
}
|
||||
}
|
||||
if (ReaderWorkspaceTopAction.BOOKMARK in topActions && onToggleBookmark != null) {
|
||||
IconButton(onClick = onToggleBookmark, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||
contentDescription = if (isBookmarked) "Remove bookmark" else "Add bookmark"
|
||||
)
|
||||
}
|
||||
}
|
||||
if (ReaderWorkspaceTopAction.FULL_SCREEN in topActions && onEnterFullscreen != null) {
|
||||
IconButton(onClick = onEnterFullscreen, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Fullscreen, contentDescription = "Enter full screen")
|
||||
}
|
||||
}
|
||||
if (hasRightPanel) {
|
||||
IconButton(onClick = onToggleRightPanel, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Tune, contentDescription = if (rightPanelOpen) "Hide reader tools" else "Show reader tools")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderWorkspaceTopBanner(
|
||||
bannerMessage: BannerMessage?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = bannerMessage != null,
|
||||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
color = if (bannerMessage?.isError == true) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Text(
|
||||
text = bannerMessage?.message.orEmpty(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
color = if (bannerMessage?.isError == true) {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
|||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.material.icons.filled.Home
|
|||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
|
|
@ -66,6 +68,7 @@ import androidx.compose.ui.unit.dp
|
|||
import com.aryan.reader.shared.AppContrastOption
|
||||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.CustomAppTheme
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
|
||||
enum class SharedAppTab {
|
||||
HOME,
|
||||
|
|
@ -73,6 +76,7 @@ enum class SharedAppTab {
|
|||
SHELVES,
|
||||
CATALOGS,
|
||||
READER,
|
||||
SETTINGS,
|
||||
CUSTOM_FONTS,
|
||||
SUPPORT,
|
||||
FEEDBACK,
|
||||
|
|
@ -89,11 +93,13 @@ fun SharedAppShell(
|
|||
appTextDimFactorDark: Float = 1.0f,
|
||||
appSeedColor: Color? = null,
|
||||
customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
isTabsEnabled: Boolean = false,
|
||||
isTabsEnabled: Boolean = true,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit = {},
|
||||
onSyncRequested: () -> Unit,
|
||||
onFolderMetadataSyncRequested: (() -> Unit)? = null,
|
||||
onAppThemeModeChange: (AppThemeMode) -> Unit = {},
|
||||
onAppContrastOptionChange: (AppContrastOption) -> Unit = {},
|
||||
onAppTextDimFactorLightChange: (Float) -> Unit = {},
|
||||
|
|
@ -105,10 +111,12 @@ fun SharedAppShell(
|
|||
onAiSettingsRequested: (() -> Unit)? = null,
|
||||
content: @Composable (SharedAppTab) -> Unit
|
||||
) {
|
||||
val shellModel = remember(selectedTab, onAiSettingsRequested != null) {
|
||||
val aiSettingsAvailable = onAiSettingsRequested != null && featurePolicy.aiAndCloud
|
||||
val shellModel = remember(selectedTab, aiSettingsAvailable, featurePolicy) {
|
||||
sharedAppShellModel(
|
||||
selectedTab = selectedTab,
|
||||
aiSettingsAvailable = onAiSettingsRequested != null
|
||||
aiSettingsAvailable = aiSettingsAvailable,
|
||||
featurePolicy = featurePolicy
|
||||
)
|
||||
}
|
||||
var showToolsPanel by remember { mutableStateOf(false) }
|
||||
|
|
@ -126,20 +134,22 @@ fun SharedAppShell(
|
|||
) {
|
||||
val useSidebar = maxWidth >= 900.dp
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (useSidebar) {
|
||||
SharedAppSidebar(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
)
|
||||
if (shellModel.showPrimaryNavigation) {
|
||||
if (useSidebar) {
|
||||
SharedAppSidebar(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) }
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
|
|
@ -165,7 +175,7 @@ fun SharedAppShell(
|
|||
.fillMaxHeight()
|
||||
.widthIn(max = 390.dp),
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
aiSettingsAvailable = onAiSettingsRequested != null,
|
||||
toolActions = shellModel.toolActions,
|
||||
onClose = { showToolsPanel = false },
|
||||
onImportFiles = {
|
||||
showToolsPanel = false
|
||||
|
|
@ -179,6 +189,12 @@ fun SharedAppShell(
|
|||
showToolsPanel = false
|
||||
onSyncRequested()
|
||||
},
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested?.let { syncMetadata ->
|
||||
{
|
||||
showToolsPanel = false
|
||||
syncMetadata()
|
||||
}
|
||||
},
|
||||
onAppThemeRequested = {
|
||||
showToolsPanel = false
|
||||
showAppThemeSettings = true
|
||||
|
|
@ -251,7 +267,7 @@ private fun SharedAppSidebar(
|
|||
Spacer(Modifier.weight(1f))
|
||||
HorizontalDivider()
|
||||
SharedSidebarButton(
|
||||
label = "Tools",
|
||||
label = "Settings",
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onToolsClick
|
||||
)
|
||||
|
|
@ -277,7 +293,7 @@ private fun SharedAppCompactRail(
|
|||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onToolsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Tools")
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -344,16 +360,26 @@ private fun SharedSidebarButton(
|
|||
private fun SharedToolsPanel(
|
||||
modifier: Modifier,
|
||||
isTabsEnabled: Boolean,
|
||||
aiSettingsAvailable: Boolean,
|
||||
toolActions: List<SharedAppToolAction>,
|
||||
onClose: () -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncRequested: () -> Unit,
|
||||
onFolderMetadataSyncRequested: (() -> Unit)?,
|
||||
onAppThemeRequested: () -> Unit,
|
||||
onAiSettingsRequested: () -> Unit,
|
||||
onOpenTab: (SharedAppTab) -> Unit,
|
||||
onTabsEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
val hasLibraryActions = SharedAppToolAction.IMPORT_FILES in toolActions ||
|
||||
SharedAppToolAction.IMPORT_FOLDER in toolActions ||
|
||||
SharedAppToolAction.SYNC in toolActions
|
||||
val hasSettingsActions = SharedAppToolAction.AI_SETTINGS in toolActions ||
|
||||
SharedAppToolAction.CUSTOM_FONTS in toolActions
|
||||
val hasProjectActions = SharedAppToolAction.HELP_FEEDBACK in toolActions ||
|
||||
SharedAppToolAction.SUPPORT in toolActions ||
|
||||
SharedAppToolAction.ABOUT in toolActions
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
|
|
@ -377,60 +403,91 @@ private fun SharedToolsPanel(
|
|||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Library") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Files")
|
||||
if (hasLibraryActions) {
|
||||
SharedToolsSection("Library") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
if (SharedAppToolAction.IMPORT_FILES in toolActions) {
|
||||
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import files")
|
||||
}
|
||||
}
|
||||
if (SharedAppToolAction.IMPORT_FOLDER in toolActions) {
|
||||
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add folder")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Folder")
|
||||
if (SharedAppToolAction.SYNC in toolActions) {
|
||||
if (onFolderMetadataSyncRequested == null) {
|
||||
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Sync folders")
|
||||
}
|
||||
} else {
|
||||
SharedToolRow(Icons.Default.Sync, "Sync metadata", onFolderMetadataSyncRequested)
|
||||
SharedToolRow(Icons.Default.Search, "Full scan") {
|
||||
onSyncRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Sync folders")
|
||||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Appearance") {
|
||||
SharedToolRow(
|
||||
icon = Icons.Default.Palette,
|
||||
title = "App theme",
|
||||
onClick = onAppThemeRequested
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = isTabsEnabled,
|
||||
onCheckedChange = onTabsEnabledChange
|
||||
if (SharedAppToolAction.APP_THEME in toolActions) {
|
||||
SharedToolRow(
|
||||
icon = Icons.Default.Palette,
|
||||
title = "App theme",
|
||||
onClick = onAppThemeRequested
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Settings") {
|
||||
if (aiSettingsAvailable) {
|
||||
SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested)
|
||||
if (SharedAppToolAction.TABS_TOGGLE in toolActions) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = isTabsEnabled,
|
||||
onCheckedChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
}
|
||||
|
||||
SharedToolsSection("Project") {
|
||||
SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) }
|
||||
if (hasSettingsActions) {
|
||||
SharedToolsSection("Settings") {
|
||||
if (SharedAppToolAction.AI_SETTINGS in toolActions) {
|
||||
SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested)
|
||||
}
|
||||
if (SharedAppToolAction.CUSTOM_FONTS in toolActions) {
|
||||
SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasProjectActions) {
|
||||
SharedToolsSection("Project") {
|
||||
if (SharedAppToolAction.HELP_FEEDBACK in toolActions) {
|
||||
SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
}
|
||||
if (SharedAppToolAction.SUPPORT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
}
|
||||
if (SharedAppToolAction.ABOUT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
|
@ -479,6 +536,7 @@ private val SharedAppTab.label: String
|
|||
SharedAppTab.SHELVES -> "Shelves"
|
||||
SharedAppTab.CATALOGS -> "OPDS"
|
||||
SharedAppTab.READER -> "Reader"
|
||||
SharedAppTab.SETTINGS -> "Settings"
|
||||
SharedAppTab.CUSTOM_FONTS -> "Custom fonts"
|
||||
SharedAppTab.SUPPORT -> "Support"
|
||||
SharedAppTab.FEEDBACK -> "Feedback"
|
||||
|
|
@ -492,6 +550,7 @@ private val SharedAppTab.icon: ImageVector
|
|||
SharedAppTab.SHELVES -> Icons.Default.Folder
|
||||
SharedAppTab.CATALOGS -> Icons.Default.Cloud
|
||||
SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook
|
||||
SharedAppTab.SETTINGS -> Icons.Default.Settings
|
||||
SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedAppTab.SUPPORT -> Icons.Default.Favorite
|
||||
SharedAppTab.FEEDBACK -> Icons.Default.Feedback
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.horizontalScroll
|
|||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -40,8 +41,8 @@ import androidx.compose.material3.HorizontalDivider
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Typography
|
||||
|
|
@ -65,7 +66,9 @@ import androidx.compose.ui.graphics.luminance
|
|||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -75,7 +78,13 @@ import com.aryan.reader.shared.AppThemeMode
|
|||
import com.aryan.reader.shared.CustomAppTheme
|
||||
import com.materialkolor.PaletteStyle
|
||||
import com.materialkolor.dynamicColorScheme
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.random.Random
|
||||
|
||||
private val SharedLightColorScheme = lightColorScheme(
|
||||
|
|
@ -524,7 +533,7 @@ private fun SharedCreateAppThemeDialog(
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Theme name") },
|
||||
|
|
@ -616,7 +625,248 @@ private fun SharedCreateAppThemeDialog(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSpectrumBox(
|
||||
fun SharedHsvColorPickerDialog(
|
||||
initialColor: Color,
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Color) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
preview: @Composable (Color) -> Unit = {}
|
||||
) {
|
||||
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val color = hsv.toComposeColor()
|
||||
|
||||
fun updateFromColor(nextColor: Color) {
|
||||
hsv = nextColor.toSharedHsvColor()
|
||||
}
|
||||
|
||||
SharedReaderModalLayer(onDismiss = onDismiss) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val dialogHorizontalPadding = 24.dp
|
||||
val dialogAvailableWidth = (maxWidth - dialogHorizontalPadding - dialogHorizontalPadding).coerceAtLeast(0.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(dialogHorizontalPadding)
|
||||
.width(sharedReaderPopupWidth(dialogAvailableWidth))
|
||||
.heightIn(max = 600.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 16.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
preview(color)
|
||||
|
||||
SharedHsvWheel(
|
||||
hue = hsv.hue,
|
||||
saturation = hsv.saturation,
|
||||
currentColor = color,
|
||||
onHueSatChanged = { hue, saturation ->
|
||||
hsv = hsv.copy(hue = hue, saturation = saturation)
|
||||
},
|
||||
modifier = Modifier.size(240.dp)
|
||||
)
|
||||
|
||||
SharedBrightnessSlider(
|
||||
hue = hsv.hue,
|
||||
saturation = hsv.saturation,
|
||||
value = hsv.value,
|
||||
onValueChanged = { hsv = hsv.copy(value = it) },
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SharedColorComparePill(
|
||||
oldColor = initialColor,
|
||||
newColor = color,
|
||||
modifier = Modifier.width(64.dp).height(36.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1.6f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SharedHexInput(color = color, onHexChanged = { updateFromColor(it) })
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(2.4f),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
SharedRgbInputColumn(
|
||||
label = "R",
|
||||
value = color.red,
|
||||
onValueChange = { updateFromColor(color.copy(red = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SharedRgbInputColumn(
|
||||
label = "G",
|
||||
value = color.green,
|
||||
onValueChange = { updateFromColor(color.copy(green = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SharedRgbInputColumn(
|
||||
label = "B",
|
||||
value = color.blue,
|
||||
onValueChange = { updateFromColor(color.copy(blue = it)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(
|
||||
onClick = { onSave(color) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = color,
|
||||
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
) {
|
||||
Text("Save", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedHsvWheel(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
val paddingPx = touchPadding.toPx()
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val selection = sharedHsvWheelSelection(
|
||||
offsetX = offset.x,
|
||||
offsetY = offset.y,
|
||||
width = size.width.toFloat(),
|
||||
height = size.height.toFloat(),
|
||||
paddingPx = paddingPx
|
||||
)
|
||||
onHueSatChanged(selection.hue, selection.saturation)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val wheelRadius = ((min(size.width, size.height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f)
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val topLeft = Offset(center.x - wheelRadius, center.y - wheelRadius)
|
||||
val wheelSize = Size(wheelRadius * 2f, wheelRadius * 2f)
|
||||
val segments = 180
|
||||
val sweep = 360f / segments
|
||||
|
||||
repeat(segments) { index ->
|
||||
val segmentHue = index * sweep
|
||||
drawArc(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color.White, Color.hsv(segmentHue, 1f, 1f)),
|
||||
center = center,
|
||||
radius = wheelRadius
|
||||
),
|
||||
startAngle = segmentHue,
|
||||
sweepAngle = sweep + 0.8f,
|
||||
useCenter = true,
|
||||
topLeft = topLeft,
|
||||
size = wheelSize
|
||||
)
|
||||
}
|
||||
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.16f),
|
||||
radius = wheelRadius,
|
||||
center = center,
|
||||
style = Stroke(width = 1.dp.toPx())
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val wheelRadius = ((min(size.width, size.height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f)
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val angle = hue.normalizedHue().toDouble() * PI / 180.0
|
||||
val radius = saturation.coerceIn(0f, 1f) * wheelRadius
|
||||
val pointer = Offset(
|
||||
x = center.x + (cos(angle).toFloat() * radius),
|
||||
y = center.y + (sin(angle).toFloat() * radius)
|
||||
)
|
||||
val pointerRadius = 10.dp.toPx()
|
||||
val strokeWidth = 2.dp.toPx()
|
||||
|
||||
drawCircle(
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
radius = pointerRadius + 1.dp.toPx(),
|
||||
center = Offset(pointer.x, pointer.y + 1.dp.toPx())
|
||||
)
|
||||
drawCircle(
|
||||
color = currentColor.copy(alpha = 1f),
|
||||
radius = pointerRadius,
|
||||
center = pointer
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = pointerRadius,
|
||||
center = pointer,
|
||||
style = Stroke(width = strokeWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedSpectrumBox(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
currentColor: Color,
|
||||
|
|
@ -702,7 +952,7 @@ private fun SharedSpectrumBox(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBrightnessSlider(
|
||||
fun SharedBrightnessSlider(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
value: Float,
|
||||
|
|
@ -747,7 +997,7 @@ private fun SharedBrightnessSlider(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedRgbInputColumn(
|
||||
fun SharedRgbInputColumn(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
|
|
@ -774,13 +1024,17 @@ private fun SharedRgbInput(
|
|||
value: Int,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
var text by remember(value) { mutableStateOf(value.coerceIn(0, 255).toString()) }
|
||||
var textFieldValue by remember(value) {
|
||||
val text = value.coerceIn(0, 255).toString()
|
||||
mutableStateOf(TextFieldValue(text, TextRange(text.length)))
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
value = textFieldValue,
|
||||
onValueChange = { nextValue ->
|
||||
val newText = nextValue.text
|
||||
if (newText.length <= 3 && newText.all { it.isDigit() }) {
|
||||
text = newText
|
||||
textFieldValue = nextValue
|
||||
newText.toIntOrNull()?.let { channel ->
|
||||
onValueChange(channel.coerceIn(0, 255) / 255f)
|
||||
}
|
||||
|
|
@ -802,12 +1056,14 @@ private fun SharedRgbInput(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedHexInput(
|
||||
fun SharedHexInput(
|
||||
color: Color,
|
||||
onHexChanged: (Color) -> Unit
|
||||
) {
|
||||
val hexValue = color.toSharedHexString().removePrefix("#")
|
||||
var text by remember(hexValue) { mutableStateOf(hexValue) }
|
||||
var textFieldValue by remember(hexValue) {
|
||||
mutableStateOf(TextFieldValue(hexValue, TextRange(hexValue.length)))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -825,12 +1081,16 @@ private fun SharedHexInput(
|
|||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
value = textFieldValue,
|
||||
onValueChange = { nextValue ->
|
||||
val newText = nextValue.text
|
||||
if (newText.length <= 6) {
|
||||
val uppercased = newText.uppercase()
|
||||
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
|
||||
text = uppercased
|
||||
textFieldValue = nextValue.copy(
|
||||
text = uppercased,
|
||||
selection = TextRange(nextValue.selection.end.coerceIn(0, uppercased.length))
|
||||
)
|
||||
if (uppercased.length == 6) {
|
||||
uppercased.toSharedHexColorOrNull()?.let(onHexChanged)
|
||||
}
|
||||
|
|
@ -852,7 +1112,7 @@ private fun SharedHexInput(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedColorComparePill(
|
||||
fun SharedColorComparePill(
|
||||
oldColor: Color,
|
||||
newColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
|
|
@ -943,6 +1203,27 @@ internal data class SharedHsvColor(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun sharedHsvWheelSelection(
|
||||
offsetX: Float,
|
||||
offsetY: Float,
|
||||
width: Float,
|
||||
height: Float,
|
||||
paddingPx: Float = 0f
|
||||
): SharedHsvColor {
|
||||
val wheelRadius = ((min(width, height) - (paddingPx * 2f)) / 2f).coerceAtLeast(1f)
|
||||
val centerX = width / 2f
|
||||
val centerY = height / 2f
|
||||
val dx = offsetX - centerX
|
||||
val dy = offsetY - centerY
|
||||
val hue = (atan2(dy.toDouble(), dx.toDouble()) * 180.0 / PI).toFloat().normalizedHue()
|
||||
val saturation = (sqrt(((dx * dx) + (dy * dy)).toDouble()).toFloat() / wheelRadius).coerceIn(0f, 1f)
|
||||
return SharedHsvColor(
|
||||
hue = hue,
|
||||
saturation = saturation,
|
||||
value = 1f
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Color.toSharedHsvColor(): SharedHsvColor {
|
||||
val maximum = maxOf(red, green, blue)
|
||||
val minimum = minOf(red, green, blue)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,44 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -28,9 +49,17 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.Tag
|
||||
import com.aryan.reader.shared.cardTitle
|
||||
|
|
@ -51,7 +80,7 @@ fun SharedTextInputDialog(
|
|||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(label) },
|
||||
|
|
@ -121,7 +150,12 @@ fun SharedAddToShelfDialog(
|
|||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(shelf.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
shelf.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text("${shelf.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
|
@ -145,98 +179,719 @@ fun SharedAddToShelfDialog(
|
|||
@Composable
|
||||
fun SharedBookInfoDialog(
|
||||
book: BookItem,
|
||||
knownTags: List<Tag> = emptyList(),
|
||||
initiallyEditing: Boolean = false,
|
||||
canEditEmbeddedMetadata: Boolean = book.type == FileType.EPUB,
|
||||
canRenameDisplayName: Boolean = true,
|
||||
canRestoreEmbeddedMetadata: Boolean = canEditEmbeddedMetadata,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
onSave: (BookItem) -> Unit,
|
||||
onRestore: (BookItem) -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(book.cardTitle()) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SharedInfoRow("File", book.displayName)
|
||||
SharedInfoRow("Type", book.type.name)
|
||||
SharedInfoRow("Author", book.author.orEmpty().ifBlank { "Unknown" })
|
||||
SharedInfoRow("Path", book.path.orEmpty().ifBlank { "Not available" })
|
||||
SharedInfoRow("Size", formatFileSize(book.fileSize))
|
||||
SharedInfoRow("Progress", "${(book.progressPercentage ?: 0f).toInt()}%")
|
||||
if (!book.seriesName.isNullOrBlank()) {
|
||||
SharedInfoRow("Series", listOfNotNull(book.seriesName, book.seriesIndex?.toString()).joinToString(" #"))
|
||||
}
|
||||
if (book.tags.isNotEmpty()) {
|
||||
SharedInfoRow("Tags", book.tags.joinToString { it.name })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onEdit) {
|
||||
Text("Edit")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var isEditing by remember(book.id, initiallyEditing) { mutableStateOf(initiallyEditing) }
|
||||
var titleInput by remember(book.id, book.title) { mutableStateOf(book.title.orEmpty()) }
|
||||
var authorInput by remember(book.id, book.author) { mutableStateOf(book.author.orEmpty()) }
|
||||
var seriesInput by remember(book.id, book.seriesName) { mutableStateOf(book.seriesName.orEmpty()) }
|
||||
var seriesIndexInput by remember(book.id, book.seriesIndex) {
|
||||
mutableStateOf(book.seriesIndex?.formatMetadataNumber().orEmpty())
|
||||
}
|
||||
var descriptionInput by remember(book.id, book.description) { mutableStateOf(book.description.orEmpty()) }
|
||||
var displayNameInput by remember(book.id, book.displayName) { mutableStateOf(book.displayName) }
|
||||
var tagInput by remember(book.id, book.tags) { mutableStateOf(book.tags.joinToString(", ") { it.name }) }
|
||||
var showRestoreConfirmation by remember(book.id) { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun SharedBookEditDialog(
|
||||
book: BookItem,
|
||||
knownTags: List<Tag>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (BookItem) -> Unit
|
||||
) {
|
||||
var title by remember(book.id) { mutableStateOf(book.title.orEmpty()) }
|
||||
var author by remember(book.id) { mutableStateOf(book.author.orEmpty()) }
|
||||
var seriesName by remember(book.id) { mutableStateOf(book.seriesName.orEmpty()) }
|
||||
var seriesIndex by remember(book.id) { mutableStateOf(book.seriesIndex?.toString().orEmpty()) }
|
||||
var tagText by remember(book.id) { mutableStateOf(book.tags.joinToString(", ") { it.name }) }
|
||||
val hasOriginalMetadata = book.hasOriginalMetadata()
|
||||
val hasMetadataChanges = book.hasMetadataChanges()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit book") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = author, onValueChange = { author = it }, label = { Text("Author") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = seriesName, onValueChange = { seriesName = it }, label = { Text("Series") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = seriesIndex, onValueChange = { seriesIndex = it }, label = { Text("Series index") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = tagText, onValueChange = { tagText = it }, label = { Text("Tags, comma separated") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text("Existing: ${knownTags.joinToString { it.name }}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSave(
|
||||
book.copy(
|
||||
title = title.trim().ifBlank { null },
|
||||
author = author.trim().ifBlank { null },
|
||||
seriesName = seriesName.trim().ifBlank { null },
|
||||
seriesIndex = seriesIndex.toDoubleOrNull(),
|
||||
tags = parseTagList(tagText, knownTags)
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SharedBookInfoTopBar(
|
||||
title = if (isEditing) {
|
||||
if (canEditEmbeddedMetadata) "Edit EPUB metadata" else "Rename in app"
|
||||
} else {
|
||||
"Book information"
|
||||
},
|
||||
subtitle = book.cardTitle(),
|
||||
onClose = {
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (isEditing) {
|
||||
if (canEditEmbeddedMetadata) {
|
||||
SharedBookMetadataEditContent(
|
||||
titleInput = titleInput,
|
||||
onTitleChange = { titleInput = it },
|
||||
authorInput = authorInput,
|
||||
onAuthorChange = { authorInput = it },
|
||||
seriesInput = seriesInput,
|
||||
onSeriesChange = { seriesInput = it },
|
||||
seriesIndexInput = seriesIndexInput,
|
||||
onSeriesIndexChange = { seriesIndexInput = it },
|
||||
descriptionInput = descriptionInput,
|
||||
onDescriptionChange = { descriptionInput = it },
|
||||
tagInput = tagInput,
|
||||
onTagChange = { tagInput = it },
|
||||
knownTags = knownTags
|
||||
)
|
||||
} else if (canRenameDisplayName) {
|
||||
SharedBookDisplayNameEditContent(
|
||||
displayNameInput = displayNameInput,
|
||||
onDisplayNameChange = { displayNameInput = it },
|
||||
tagInput = tagInput,
|
||||
onTagChange = { tagInput = it },
|
||||
knownTags = knownTags
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SharedBookMetadataInfoContent(
|
||||
book = book,
|
||||
hasMetadataChanges = hasMetadataChanges,
|
||||
onCopyPath = {
|
||||
book.path?.takeIf { it.isNotBlank() }?.let { clipboard.setText(AnnotatedString(it)) }
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SharedBookInfoBottomBar(
|
||||
isEditing = isEditing,
|
||||
canEdit = canEditEmbeddedMetadata || canRenameDisplayName,
|
||||
canRestore = canRestoreEmbeddedMetadata && hasOriginalMetadata && (hasMetadataChanges || isEditing),
|
||||
editLabel = if (canEditEmbeddedMetadata) "Edit metadata" else "Rename",
|
||||
onCancel = {
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onRestore = { showRestoreConfirmation = true },
|
||||
onSave = {
|
||||
val updated = if (canEditEmbeddedMetadata) {
|
||||
book.copy(
|
||||
title = titleInput.toMetadataValue()
|
||||
?: book.displayName.substringBeforeLast('.', book.displayName),
|
||||
author = authorInput.toMetadataValue(),
|
||||
seriesName = seriesInput.toMetadataValue(),
|
||||
seriesIndex = seriesIndexInput.toSeriesIndexOrNull(),
|
||||
description = descriptionInput.toMetadataValue(),
|
||||
originalTitle = book.originalTitle ?: book.title,
|
||||
originalAuthor = book.originalAuthor ?: book.author,
|
||||
originalSeriesName = book.originalSeriesName ?: book.seriesName,
|
||||
originalSeriesIndex = book.originalSeriesIndex ?: book.seriesIndex,
|
||||
originalDescription = book.originalDescription ?: book.description,
|
||||
tags = parseTagList(tagInput, knownTags)
|
||||
)
|
||||
} else {
|
||||
book.copy(
|
||||
displayName = displayNameInput.toMetadataValue() ?: book.displayName,
|
||||
tags = parseTagList(tagInput, knownTags)
|
||||
)
|
||||
}
|
||||
onSave(updated)
|
||||
onDismiss()
|
||||
},
|
||||
onEdit = { isEditing = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoRow(label: String, value: String) {
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium)
|
||||
if (showRestoreConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRestoreConfirmation = false },
|
||||
icon = { Icon(Icons.Default.Restore, contentDescription = null) },
|
||||
title = { Text("Restore original metadata?") },
|
||||
text = {
|
||||
Text(
|
||||
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
showRestoreConfirmation = false
|
||||
onRestore(book.restoredOriginalMetadata())
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text("Restore")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRestoreConfirmation = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookInfoTopBar(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookMetadataInfoContent(
|
||||
book: BookItem,
|
||||
hasMetadataChanges: Boolean,
|
||||
onCopyPath: () -> Unit
|
||||
) {
|
||||
SharedInfoCard {
|
||||
Text(
|
||||
book.cardTitle(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
book.author
|
||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
val provenance = when {
|
||||
book.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited"
|
||||
book.type == FileType.EPUB -> "Metadata from EPUB file"
|
||||
hasMetadataChanges -> "Display name changed in app"
|
||||
else -> "Metadata from file"
|
||||
}
|
||||
Text(
|
||||
provenance,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (hasMetadataChanges) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Metadata") {
|
||||
SharedInfoRowDetailed("Title", book.title?.takeIf { it.isNotBlank() } ?: book.displayName, maxLines = 3)
|
||||
book.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
||||
SharedInfoRowDetailed("Author", it, maxLines = 2)
|
||||
}
|
||||
book.seriesLabel()?.let {
|
||||
SharedInfoRowDetailed("Series", it, maxLines = 2)
|
||||
}
|
||||
SharedInfoRowDetailed("Format", book.type.name)
|
||||
SharedInfoRowDetailed("Size", formatFileSize(book.fileSize))
|
||||
SharedInfoRowDetailed("Reading", book.readingProgressText(), maxLines = 2)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "File") {
|
||||
SharedInfoRowDetailed("File name", book.displayName, maxLines = 2)
|
||||
SharedInfoRowDetailed("Location", book.path.orEmpty().ifBlank { "Not available" }, maxLines = 4, onCopy = onCopyPath)
|
||||
book.sourceFolder?.takeIf { it.isNotBlank() }?.let {
|
||||
SharedInfoRowDetailed("Source folder", it, maxLines = 3)
|
||||
}
|
||||
}
|
||||
|
||||
book.description?.takeIf { it.isNotBlank() }?.let { summary ->
|
||||
SharedInfoSection(title = "Summary") {
|
||||
SharedExpandableSummaryText(summary, collapsedMaxLines = 4)
|
||||
}
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Tags") {
|
||||
if (book.tags.isEmpty()) {
|
||||
Text(
|
||||
"No tags assigned",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
book.tags.forEach { tag ->
|
||||
AssistChip(onClick = {}, label = { Text(tag.name) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookMetadataEditContent(
|
||||
titleInput: String,
|
||||
onTitleChange: (String) -> Unit,
|
||||
authorInput: String,
|
||||
onAuthorChange: (String) -> Unit,
|
||||
seriesInput: String,
|
||||
onSeriesChange: (String) -> Unit,
|
||||
seriesIndexInput: String,
|
||||
onSeriesIndexChange: (String) -> Unit,
|
||||
descriptionInput: String,
|
||||
onDescriptionChange: (String) -> Unit,
|
||||
tagInput: String,
|
||||
onTagChange: (String) -> Unit,
|
||||
knownTags: List<Tag>
|
||||
) {
|
||||
SharedInfoSection(title = "Editable metadata") {
|
||||
SharedStableOutlinedTextField(
|
||||
value = titleInput,
|
||||
onValueChange = onTitleChange,
|
||||
label = { Text("Title") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "title"
|
||||
)
|
||||
SharedStableOutlinedTextField(
|
||||
value = authorInput,
|
||||
onValueChange = onAuthorChange,
|
||||
label = { Text("Author") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 2,
|
||||
selectionKey = "author"
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = seriesInput,
|
||||
onValueChange = onSeriesChange,
|
||||
label = { Text("Series") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2,
|
||||
selectionKey = "series"
|
||||
)
|
||||
SharedStableOutlinedTextField(
|
||||
value = seriesIndexInput,
|
||||
onValueChange = onSeriesIndexChange,
|
||||
label = { Text("#") },
|
||||
modifier = Modifier.width(96.dp),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
selectionKey = "seriesIndex"
|
||||
)
|
||||
}
|
||||
SharedStableOutlinedTextField(
|
||||
value = descriptionInput,
|
||||
onValueChange = onDescriptionChange,
|
||||
label = { Text("Summary") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp),
|
||||
minLines = 4,
|
||||
maxLines = 10,
|
||||
selectionKey = "description"
|
||||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Library tags") {
|
||||
SharedStableOutlinedTextField(
|
||||
value = tagInput,
|
||||
onValueChange = onTagChange,
|
||||
label = { Text("Tags, comma separated") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "tags"
|
||||
)
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text(
|
||||
"Existing: ${knownTags.joinToString { it.name }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookDisplayNameEditContent(
|
||||
displayNameInput: String,
|
||||
onDisplayNameChange: (String) -> Unit,
|
||||
tagInput: String,
|
||||
onTagChange: (String) -> Unit,
|
||||
knownTags: List<Tag>
|
||||
) {
|
||||
SharedInfoSection(title = "Display name") {
|
||||
SharedStableOutlinedTextField(
|
||||
value = displayNameInput,
|
||||
onValueChange = onDisplayNameChange,
|
||||
label = { Text("Name shown in Reader") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "displayName"
|
||||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Library tags") {
|
||||
SharedStableOutlinedTextField(
|
||||
value = tagInput,
|
||||
onValueChange = onTagChange,
|
||||
label = { Text("Tags, comma separated") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "renameTags"
|
||||
)
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text(
|
||||
"Existing: ${knownTags.joinToString { it.name }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBookInfoBottomBar(
|
||||
isEditing: Boolean,
|
||||
canEdit: Boolean,
|
||||
canRestore: Boolean,
|
||||
editLabel: String,
|
||||
onCancel: () -> Unit,
|
||||
onRestore: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (canRestore) {
|
||||
OutlinedButton(
|
||||
onClick = onRestore,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Restore")
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(if (isEditing) "Cancel" else "Close")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (isEditing) {
|
||||
Button(onClick = onSave) {
|
||||
Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
}
|
||||
} else if (canEdit) {
|
||||
Button(onClick = onEdit) {
|
||||
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(editLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoSection(
|
||||
title: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedInfoCard {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoCard(content: @Composable ColumnScope.() -> Unit) {
|
||||
OutlinedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInfoRowDetailed(
|
||||
label: String,
|
||||
value: String,
|
||||
maxLines: Int = 1,
|
||||
onCopy: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.width(112.dp)
|
||||
.padding(top = 2.dp)
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
SharedExpandableValueText(value, collapsedMaxLines = maxLines)
|
||||
}
|
||||
if (onCopy != null && value != "Not available") {
|
||||
TextButton(
|
||||
onClick = onCopy,
|
||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(30.dp)
|
||||
) {
|
||||
Text("Copy")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedExpandableValueText(
|
||||
value: String,
|
||||
collapsedMaxLines: Int
|
||||
) {
|
||||
var expanded by remember(value) { mutableStateOf(false) }
|
||||
val canExpand = collapsedMaxLines < Int.MAX_VALUE && (value.length > 120 || value.contains('\n'))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else collapsedMaxLines,
|
||||
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
if (canExpand) {
|
||||
SharedMoreButton(expanded = expanded, onClick = { expanded = !expanded })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedExpandableSummaryText(
|
||||
value: String,
|
||||
collapsedMaxLines: Int
|
||||
) {
|
||||
var expanded by remember(value) { mutableStateOf(false) }
|
||||
val renderableSummary = remember(value) {
|
||||
if (value.looksLikeHtml()) value.htmlToMarkdownSummary() else value
|
||||
}
|
||||
val canExpand = value.length > 220 || value.count { it == '\n' } >= collapsedMaxLines || value.looksLikeHtml()
|
||||
val contentModifier = if (expanded) {
|
||||
Modifier.fillMaxWidth()
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = (collapsedMaxLines * 26).dp)
|
||||
.clipToBounds()
|
||||
}
|
||||
|
||||
Box(modifier = contentModifier) {
|
||||
SharedMarkdownText(
|
||||
markdown = renderableSummary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (canExpand) {
|
||||
SharedMoreButton(expanded = expanded, onClick = { expanded = !expanded })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedMoreButton(
|
||||
expanded: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) {
|
||||
Text(if (expanded) "Less" else "...more")
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun BookItem.hasOriginalMetadata(): Boolean {
|
||||
return listOf(originalTitle, originalAuthor, originalSeriesName, originalDescription).any { !it.isNullOrBlank() } ||
|
||||
originalSeriesIndex != null
|
||||
}
|
||||
|
||||
private fun BookItem.hasMetadataChanges(): Boolean {
|
||||
return metadataValueChanged(title, originalTitle) ||
|
||||
metadataValueChanged(author, originalAuthor) ||
|
||||
metadataValueChanged(seriesName, originalSeriesName) ||
|
||||
seriesIndex != originalSeriesIndex ||
|
||||
metadataValueChanged(description, originalDescription)
|
||||
}
|
||||
|
||||
private fun BookItem.restoredOriginalMetadata(): BookItem {
|
||||
return copy(
|
||||
title = originalTitle?.takeIf { it.isNotBlank() } ?: displayName.substringBeforeLast('.', displayName),
|
||||
author = originalAuthor,
|
||||
seriesName = originalSeriesName,
|
||||
seriesIndex = originalSeriesIndex,
|
||||
description = originalDescription
|
||||
)
|
||||
}
|
||||
|
||||
private fun metadataValueChanged(current: String?, original: String?): Boolean {
|
||||
return current.orEmpty().trim() != original.orEmpty().trim()
|
||||
}
|
||||
|
||||
private fun BookItem.seriesLabel(): String? {
|
||||
val series = seriesName?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return seriesIndex?.takeIf { it > 0.0 }?.let { "$series #${it.formatMetadataNumber()}" } ?: series
|
||||
}
|
||||
|
||||
private fun BookItem.readingProgressText(): String {
|
||||
val progress = progressPercentage?.coerceIn(0f, 100f)
|
||||
val progressText = progress?.toDouble()?.formatMetadataNumber()?.let { "$it%" } ?: "Not started"
|
||||
val chapterIndex = readerPosition?.chapterIndex
|
||||
val locatorText = when {
|
||||
lastPageIndex != null -> "Last page ${lastPageIndex + 1}"
|
||||
chapterIndex != null -> "Chapter ${chapterIndex + 1}"
|
||||
else -> null
|
||||
}
|
||||
return listOfNotNull(progressText, locatorText).joinToString(" - ")
|
||||
}
|
||||
|
||||
private fun String.toMetadataValue(): String? {
|
||||
return trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun String.toSeriesIndexOrNull(): Double? {
|
||||
return trim()
|
||||
.replace(',', '.')
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.toDoubleOrNull()
|
||||
?.takeIf { it > 0.0 }
|
||||
}
|
||||
|
||||
private fun Double.formatMetadataNumber(): String {
|
||||
val whole = toLong()
|
||||
return if (this == whole.toDouble()) whole.toString() else toString().trimEnd('0').trimEnd('.')
|
||||
}
|
||||
|
||||
private fun String.looksLikeHtml(): Boolean {
|
||||
return contains(Regex("<\\s*/?\\s*(p|br|div|span|strong|em|ul|ol|li|h[1-6]|blockquote|a|b|i)\\b", RegexOption.IGNORE_CASE)) ||
|
||||
contains(Regex("&(#\\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);"))
|
||||
}
|
||||
|
||||
private fun String.htmlToMarkdownSummary(): String {
|
||||
var text = decodeHtmlEntities()
|
||||
.replace(Regex("(?is)<script\\b.*?</script>"), "")
|
||||
.replace(Regex("(?is)<style\\b.*?</style>"), "")
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</(p|div|section|article)\\s*>"), "\n\n")
|
||||
.replace(Regex("(?i)<li\\b[^>]*>"), "\n- ")
|
||||
.replace(Regex("(?i)</li\\s*>"), "")
|
||||
.replace(Regex("(?i)</?(ul|ol)\\b[^>]*>"), "\n")
|
||||
.replace(Regex("(?is)<h1\\b[^>]*>(.*?)</h1>")) { "# ${it.groupValues[1].stripHtmlTags()}\n\n" }
|
||||
.replace(Regex("(?is)<h2\\b[^>]*>(.*?)</h2>")) { "## ${it.groupValues[1].stripHtmlTags()}\n\n" }
|
||||
.replace(Regex("(?is)<h[3-6]\\b[^>]*>(.*?)</h[3-6]>")) { "### ${it.groupValues[1].stripHtmlTags()}\n\n" }
|
||||
.replace(Regex("(?is)<(strong|b)\\b[^>]*>(.*?)</(strong|b)>")) { "**${it.groupValues[2].stripHtmlTags()}**" }
|
||||
.replace(Regex("(?is)<(em|i)\\b[^>]*>(.*?)</(em|i)>")) { "*${it.groupValues[2].stripHtmlTags()}*" }
|
||||
.replace(Regex("(?is)<blockquote\\b[^>]*>(.*?)</blockquote>")) {
|
||||
it.groupValues[1].stripHtmlTags().lines().joinToString("\n") { line -> "> $line" } + "\n\n"
|
||||
}
|
||||
.replace(Regex("(?is)<a\\b[^>]*>(.*?)</a>")) { it.groupValues[1].stripHtmlTags() }
|
||||
.stripHtmlTags()
|
||||
.decodeHtmlEntities()
|
||||
|
||||
text = text
|
||||
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
||||
.replace(Regex(" *\\n *"), "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
return text
|
||||
}
|
||||
|
||||
private fun String.stripHtmlTags(): String {
|
||||
return replace(Regex("<[^>]+>"), " ")
|
||||
}
|
||||
|
||||
private fun String.decodeHtmlEntities(): String {
|
||||
return replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
.replace(Regex("&#(\\d+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -47,12 +47,10 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -88,6 +86,9 @@ fun SharedOpdsScreen(
|
|||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit = { entry, coverModifier ->
|
||||
SharedOpdsCoverPlaceholder(entry, coverModifier)
|
||||
},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||
|
|
@ -121,7 +122,8 @@ fun SharedOpdsScreen(
|
|||
onDownloadBook = onDownloadBook,
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { entry -> onStreamBook(entry, state.currentCatalog) },
|
||||
onEntrySelected = { selectedEntry = it }
|
||||
onEntrySelected = { selectedEntry = it },
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +215,8 @@ fun SharedOpdsScreen(
|
|||
onSearch = { query ->
|
||||
onSearch(query)
|
||||
selectedEntry = null
|
||||
}
|
||||
},
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -273,7 +276,8 @@ private fun SharedOpdsFeedView(
|
|||
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry) -> Unit,
|
||||
onEntrySelected: (OpdsEntry) -> Unit
|
||||
onEntrySelected: (OpdsEntry) -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
|
@ -298,7 +302,7 @@ private fun SharedOpdsFeedView(
|
|||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
if (showSearch) {
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search catalog") },
|
||||
|
|
@ -380,12 +384,6 @@ private fun SharedOpdsFeedView(
|
|||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
itemsIndexed(entries, key = { index, entry -> "${entry.id}_$index" }) { index, entry ->
|
||||
val nextUrl = state.currentFeed?.nextUrl
|
||||
if (index == entries.lastIndex && nextUrl != null) {
|
||||
LaunchedEffect(index, nextUrl) {
|
||||
onLoadNextPage()
|
||||
}
|
||||
}
|
||||
if (entry.isNavigation) {
|
||||
SharedOpdsNavigationCard(entry, onOpenFeedUrl)
|
||||
} else {
|
||||
|
|
@ -396,10 +394,23 @@ private fun SharedOpdsFeedView(
|
|||
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { onStreamBook(entry) },
|
||||
onClick = { onEntrySelected(entry) }
|
||||
onClick = { onEntrySelected(entry) },
|
||||
coverContent = coverContent
|
||||
)
|
||||
}
|
||||
}
|
||||
state.currentFeed?.nextUrl?.let { nextUrl ->
|
||||
item(key = "load_more_$nextUrl") {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
OutlinedButton(
|
||||
onClick = onLoadNextPage,
|
||||
enabled = !state.isLoading
|
||||
) {
|
||||
Text(if (state.isLoading) "Loading..." else "Load more")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -567,7 +578,8 @@ private fun SharedOpdsBookCard(
|
|||
onDownloadBook: (OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
|
|
@ -582,15 +594,7 @@ private fun SharedOpdsBookCard(
|
|||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 70.dp, height = 100.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
coverContent(entry, Modifier.size(width = 70.dp, height = 100.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.author?.let {
|
||||
|
|
@ -687,7 +691,8 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
onSearch: (String) -> Unit,
|
||||
coverContent: @Composable (OpdsEntry, Modifier) -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
|
|
@ -709,6 +714,24 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) {
|
||||
coverContent(entry, Modifier.size(width = 96.dp, height = 140.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
entry.series?.takeIf { it.isNotBlank() }?.let { series ->
|
||||
Text(
|
||||
text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
val secondary = listOfNotNull(
|
||||
entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" },
|
||||
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" },
|
||||
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" }
|
||||
)
|
||||
secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
localLibraryBook?.let { book ->
|
||||
Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
|
|
@ -737,14 +760,6 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
entry.series?.takeIf { it.isNotBlank() }?.let { series ->
|
||||
Text(
|
||||
text = if (entry.seriesIndex.isNullOrBlank()) series else "$series #${entry.seriesIndex}",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
if (entry.authors.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Authors", style = MaterialTheme.typography.labelLarge)
|
||||
|
|
@ -767,12 +782,6 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
val secondary = listOfNotNull(
|
||||
entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" },
|
||||
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" },
|
||||
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" }
|
||||
)
|
||||
secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text("Synopsis", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
|
|
@ -805,16 +814,17 @@ private fun SharedOpdsCatalogDialog(
|
|||
title = { Text(if (isEditMode) "Edit catalog" else "Add OPDS catalog") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true)
|
||||
OutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true)
|
||||
SharedStableOutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true, selectionKey = catalog?.id ?: "new:title")
|
||||
SharedStableOutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true, selectionKey = catalog?.id ?: "new:url")
|
||||
Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true, selectionKey = catalog?.id ?: "new:username")
|
||||
SharedStableOutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
selectionKey = catalog?.id ?: "new:password"
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -839,3 +849,15 @@ private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem
|
|||
it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCoverPlaceholder(entry: OpdsEntry, modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(entry.title.take(1).uppercase(), style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -28,6 +29,7 @@ import androidx.compose.foundation.text.BasicTextField
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
|
|
@ -68,12 +70,16 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
|||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
|
|
@ -86,7 +92,9 @@ import com.aryan.reader.shared.pdf.PdfPageBounds
|
|||
import com.aryan.reader.shared.pdf.PdfPagePoint
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.pdf.SharedPdfInkRenderData
|
||||
import com.aryan.reader.shared.pdf.SharedPdfInkRenderer
|
||||
import com.aryan.reader.shared.pdf.SharedPdfTextAnnotationDefaults
|
||||
|
|
@ -96,8 +104,10 @@ import com.aryan.reader.shared.pdf.SharedPdfTextResizeHandle
|
|||
import com.aryan.reader.shared.pdf.SharedPdfTextStyleConfig
|
||||
import com.aryan.reader.shared.pdf.movedBy
|
||||
import com.aryan.reader.shared.pdf.resizedBy
|
||||
import com.aryan.reader.shared.pdf.sharedPdfTextFontSizePx
|
||||
import com.aryan.reader.shared.pdf.sharedPdfStrokePercent
|
||||
import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange
|
||||
import com.aryan.reader.shared.pdf.withSharedPdfTextFontSize
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
val SharedPdfAnnotationDefaultTools: List<PdfInkTool> = listOf(
|
||||
|
|
@ -110,12 +120,20 @@ val SharedPdfAnnotationDefaultTools: List<PdfInkTool> = listOf(
|
|||
PdfInkTool.ERASER
|
||||
)
|
||||
|
||||
private enum class SharedPdfAnnotationSettingsPanel {
|
||||
PEN,
|
||||
HIGHLIGHTER,
|
||||
ERASER
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfAnnotationToolDock(
|
||||
selectedTool: PdfInkTool,
|
||||
selectedColor: Int,
|
||||
strokeWidth: Float,
|
||||
tools: List<PdfInkTool> = SharedPdfAnnotationDefaultTools,
|
||||
penPalette: List<Int> = SharedPdfAnnotationDefaults.penPalette,
|
||||
highlighterPalette: List<Int> = SharedPdfHighlighterPalette.defaultColors,
|
||||
onToolSelected: (PdfInkTool) -> Unit,
|
||||
onColorSelected: (Int) -> Unit,
|
||||
onStrokeWidthChange: (Float) -> Unit,
|
||||
|
|
@ -124,45 +142,101 @@ fun SharedPdfAnnotationToolDock(
|
|||
isHighlighterSnapEnabled: Boolean = false,
|
||||
onHighlighterSnapChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val strokeRange = selectedTool.sharedPdfStrokeWidthRange()
|
||||
val sliderValue = strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive)
|
||||
val showColorPalette = selectedTool != PdfInkTool.TEXT && selectedTool != PdfInkTool.ERASER
|
||||
val showStrokeSettings = selectedTool != PdfInkTool.TEXT
|
||||
val palette = if (selectedTool.isHighlighter) {
|
||||
SharedPdfAnnotationDefaults.highlighterPalette
|
||||
} else {
|
||||
SharedPdfAnnotationDefaults.penPalette
|
||||
val availableTools = tools.distinct()
|
||||
val penTools = listOf(PdfInkTool.FOUNTAIN_PEN, PdfInkTool.PEN, PdfInkTool.PENCIL)
|
||||
.filter { it in availableTools }
|
||||
val highlighterTools = listOf(PdfInkTool.HIGHLIGHTER, PdfInkTool.HIGHLIGHTER_ROUND)
|
||||
.filter { it in availableTools }
|
||||
var lastPenTool by remember { mutableStateOf(PdfInkTool.PEN) }
|
||||
var lastHighlighterTool by remember { mutableStateOf(PdfInkTool.HIGHLIGHTER) }
|
||||
var activeSettingsPanel by remember { mutableStateOf<SharedPdfAnnotationSettingsPanel?>(null) }
|
||||
|
||||
LaunchedEffect(selectedTool) {
|
||||
when {
|
||||
selectedTool in penTools -> lastPenTool = selectedTool
|
||||
selectedTool in highlighterTools -> lastHighlighterTool = selectedTool
|
||||
selectedTool != PdfInkTool.ERASER -> activeSettingsPanel = null
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = Color(0xFF1E1E1E),
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
color = Color(0xFF1E1E1E),
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
tools.distinct().chunked(4).forEach { rowTools ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
rowTools.forEach { tool ->
|
||||
SharedPdfToolButton(
|
||||
tool = tool,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = onToolSelected
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (penTools.isNotEmpty()) {
|
||||
val tool = selectedTool.takeIf { it in penTools } ?: lastPenTool.takeIf { it in penTools } ?: penTools.first()
|
||||
SharedPdfToolButton(
|
||||
tool = tool,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = {
|
||||
onToolSelected(tool)
|
||||
activeSettingsPanel = SharedPdfAnnotationSettingsPanel.PEN
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (highlighterTools.isNotEmpty()) {
|
||||
val tool = selectedTool.takeIf { it in highlighterTools }
|
||||
?: lastHighlighterTool.takeIf { it in highlighterTools }
|
||||
?: highlighterTools.first()
|
||||
SharedPdfToolButton(
|
||||
tool = tool,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = {
|
||||
onToolSelected(tool)
|
||||
activeSettingsPanel = SharedPdfAnnotationSettingsPanel.HIGHLIGHTER
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (PdfInkTool.TEXT in availableTools) {
|
||||
SharedPdfToolButton(
|
||||
tool = PdfInkTool.TEXT,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = {
|
||||
activeSettingsPanel = null
|
||||
onToolSelected(PdfInkTool.TEXT)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (PdfInkTool.ERASER in availableTools) {
|
||||
SharedPdfToolButton(
|
||||
tool = PdfInkTool.ERASER,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = {
|
||||
onToolSelected(PdfInkTool.ERASER)
|
||||
activeSettingsPanel = SharedPdfAnnotationSettingsPanel.ERASER
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(22.dp)
|
||||
.width(1.dp)
|
||||
.background(Color.White.copy(alpha = 0.18f))
|
||||
)
|
||||
|
||||
DockCircleButton(onClick = onUndo) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Undo,
|
||||
|
|
@ -180,48 +254,161 @@ fun SharedPdfAnnotationToolDock(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showColorPalette) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
palette.forEach { argb ->
|
||||
val selected = argb == selectedColor
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(argb).copy(alpha = 1f))
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) Color.White else Color.White.copy(alpha = 0.22f),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable { onColorSelected(argb) }
|
||||
activeSettingsPanel?.let { panel ->
|
||||
val toolsForPanel = when (panel) {
|
||||
SharedPdfAnnotationSettingsPanel.PEN -> penTools
|
||||
SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> highlighterTools
|
||||
SharedPdfAnnotationSettingsPanel.ERASER -> listOf(PdfInkTool.ERASER).filter { it in availableTools }
|
||||
}
|
||||
if (toolsForPanel.isNotEmpty()) {
|
||||
val panelTool = when (panel) {
|
||||
SharedPdfAnnotationSettingsPanel.PEN -> selectedTool.takeIf { it in penTools } ?: lastPenTool
|
||||
SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> selectedTool.takeIf { it in highlighterTools } ?: lastHighlighterTool
|
||||
SharedPdfAnnotationSettingsPanel.ERASER -> PdfInkTool.ERASER
|
||||
}
|
||||
SharedPdfAnnotationToolSettingsPanel(
|
||||
panel = panel,
|
||||
tools = toolsForPanel,
|
||||
selectedTool = panelTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
penPalette = penPalette,
|
||||
highlighterPalette = highlighterPalette,
|
||||
onToolSelected = { tool ->
|
||||
when (panel) {
|
||||
SharedPdfAnnotationSettingsPanel.PEN -> lastPenTool = tool
|
||||
SharedPdfAnnotationSettingsPanel.HIGHLIGHTER -> lastHighlighterTool = tool
|
||||
SharedPdfAnnotationSettingsPanel.ERASER -> Unit
|
||||
}
|
||||
onToolSelected(tool)
|
||||
},
|
||||
onColorSelected = onColorSelected,
|
||||
onStrokeWidthChange = onStrokeWidthChange,
|
||||
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
|
||||
onHighlighterSnapChange = onHighlighterSnapChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedPdfAnnotationToolSettingsPanel(
|
||||
panel: SharedPdfAnnotationSettingsPanel,
|
||||
tools: List<PdfInkTool>,
|
||||
selectedTool: PdfInkTool,
|
||||
selectedColor: Int,
|
||||
strokeWidth: Float,
|
||||
penPalette: List<Int>,
|
||||
highlighterPalette: List<Int>,
|
||||
onToolSelected: (PdfInkTool) -> Unit,
|
||||
onColorSelected: (Int) -> Unit,
|
||||
onStrokeWidthChange: (Float) -> Unit,
|
||||
isHighlighterSnapEnabled: Boolean,
|
||||
onHighlighterSnapChange: (Boolean) -> Unit
|
||||
) {
|
||||
val isEraser = panel == SharedPdfAnnotationSettingsPanel.ERASER
|
||||
val isHighlighter = panel == SharedPdfAnnotationSettingsPanel.HIGHLIGHTER
|
||||
val effectiveTool = if (isEraser) PdfInkTool.ERASER else selectedTool
|
||||
val strokeRange = effectiveTool.sharedPdfStrokeWidthRange()
|
||||
val sliderValue = strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive)
|
||||
val activeColor = if (isEraser) Color.White else Color(selectedColor)
|
||||
|
||||
Surface(
|
||||
color = Color(0xFF1E1E1E),
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (isEraser) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(104.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val diameter = (sliderValue * 800f).coerceIn(10f, 128f).dp
|
||||
Canvas(modifier = Modifier.size(diameter)) {
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = 0.3f),
|
||||
radius = size.minDimension / 2f
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = size.minDimension / 2f,
|
||||
style = Stroke(width = 2.dp.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
tools.forEach { tool ->
|
||||
SharedPdfToolButton(
|
||||
tool = tool,
|
||||
selectedTool = selectedTool,
|
||||
selectedColor = selectedColor,
|
||||
strokeWidth = strokeWidth,
|
||||
onToolSelected = onToolSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showStrokeSettings) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "Thickness ${sliderValue.sharedPdfStrokePercent(strokeRange)}",
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = onStrokeWidthChange,
|
||||
valueRange = strokeRange,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = if (selectedTool == PdfInkTool.ERASER) Color.White else Color(selectedColor).copy(alpha = 1f),
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.18f)
|
||||
if (!isEraser) {
|
||||
SharedPdfInkColorPalette(
|
||||
colors = if (isHighlighter) {
|
||||
highlighterPalette.ifEmpty { SharedPdfHighlighterPalette.defaultColors }
|
||||
} else {
|
||||
penPalette.ifEmpty { SharedPdfAnnotationDefaults.penPalette }
|
||||
},
|
||||
selectedColor = selectedColor,
|
||||
matchRgbOnly = isHighlighter,
|
||||
onColorSelected = { color ->
|
||||
onColorSelected(
|
||||
if (isHighlighter) {
|
||||
color.withSharedPdfAnnotationAlpha(Color(selectedColor).alpha)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedTool.isHighlighter) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = if (isEraser) {
|
||||
"Eraser size ${sliderValue.sharedPdfStrokePercent(strokeRange)}"
|
||||
} else {
|
||||
"Thickness ${sliderValue.sharedPdfStrokePercent(strokeRange)}"
|
||||
},
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = onStrokeWidthChange,
|
||||
valueRange = strokeRange,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = activeColor.copy(alpha = 1f),
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (isHighlighter) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -243,6 +430,158 @@ fun SharedPdfAnnotationToolDock(
|
|||
)
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
val alpha = Color(selectedColor).alpha.coerceIn(0.1f, 1f)
|
||||
Text(
|
||||
text = "Opacity ${(alpha * 100f).roundToInt()}",
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Slider(
|
||||
value = alpha,
|
||||
onValueChange = { nextAlpha ->
|
||||
onColorSelected(selectedColor.withSharedPdfAnnotationAlpha(nextAlpha))
|
||||
},
|
||||
valueRange = 0.1f..1f,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = Color(selectedColor).copy(alpha = 1f),
|
||||
inactiveTrackColor = Color.White.copy(alpha = 0.18f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedPdfInkColorPalette(
|
||||
colors: List<Int>,
|
||||
selectedColor: Int,
|
||||
matchRgbOnly: Boolean,
|
||||
onColorSelected: (Int) -> Unit
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
colors.forEach { argb ->
|
||||
val selected = if (matchRgbOnly) {
|
||||
(argb and 0x00FFFFFF) == (selectedColor and 0x00FFFFFF)
|
||||
} else {
|
||||
argb == selectedColor
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(argb).copy(alpha = 1f))
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) Color.White else Color.White.copy(alpha = 0.22f),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable { onColorSelected(argb) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfHighlighterPaletteEditor(
|
||||
palette: SharedPdfHighlighterPalette,
|
||||
onPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val sanitized = palette.sanitized()
|
||||
var editingSlot by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Highlight colors",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "Tap a color to customize it.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
) {
|
||||
sanitized.colors.forEachIndexed { index, argb ->
|
||||
val color = Color(argb)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = 1f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable { editingSlot = index },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
color = if (color.luminance() > 0.5f) Color.Black else Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editingSlot?.let { slot ->
|
||||
val initialColor = Color(sanitized.colors.getOrElse(slot) { SharedPdfHighlighterPalette.defaultColors.first() }).copy(alpha = 1f)
|
||||
SharedHsvColorPickerDialog(
|
||||
initialColor = initialColor,
|
||||
title = "Highlight color ${slot + 1}",
|
||||
onDismiss = { editingSlot = null },
|
||||
onSave = { color ->
|
||||
onPaletteChange(
|
||||
sanitized.withColorAt(
|
||||
slotIndex = slot,
|
||||
colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||
)
|
||||
)
|
||||
editingSlot = null
|
||||
}
|
||||
) { color ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("PDF highlighter", fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Saved with reader highlight transparency.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -334,19 +673,35 @@ fun SharedPdfTextBoxEditorOverlay(
|
|||
val moveHandleWidthPx = with(density) { moveHandleWidth.toPx() }
|
||||
val moveHandleHeightPx = with(density) { moveHandleHeight.toPx() }
|
||||
val moveHandleBelow = topPx + heightPx + moveHandleHeightPx + 10f <= canvasSize.height
|
||||
var textFieldValue by remember(id) {
|
||||
mutableStateOf(TextFieldValue(text, TextRange(text.length)))
|
||||
}
|
||||
|
||||
LaunchedEffect(id, style) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
LaunchedEffect(id, text) {
|
||||
if (text != textFieldValue.text) {
|
||||
textFieldValue = TextFieldValue(text, TextRange(text.length))
|
||||
}
|
||||
}
|
||||
|
||||
val fontSizePx = style.sharedPdfTextFontSizePx(canvasSize)
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
value = textFieldValue,
|
||||
onValueChange = { nextValue ->
|
||||
textFieldValue = nextValue
|
||||
if (nextValue.text != text) {
|
||||
onTextChange(nextValue.text)
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = textColor,
|
||||
fontSize = style.fontSize.sp,
|
||||
lineHeight = (style.fontSize * 1.25f).sp,
|
||||
fontSize = with(density) { fontSizePx.toSp() },
|
||||
lineHeight = with(density) { (fontSizePx * 1.25f).toSp() },
|
||||
fontWeight = if (style.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (style.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
fontFamily = sharedPdfFontFamily(style.fontName ?: style.fontPath),
|
||||
|
|
@ -540,7 +895,7 @@ fun SharedPdfTextStyleControls(
|
|||
selected = style.fontSize.toInt() == size.toInt(),
|
||||
selectedBackground = selectedBackground,
|
||||
unselectedBackground = unselectedBackground,
|
||||
onClick = { onStyleChange(style.copy(fontSize = size)) }
|
||||
onClick = { onStyleChange(style.withSharedPdfTextFontSize(size)) }
|
||||
) {
|
||||
Text(
|
||||
text = size.toInt().toString(),
|
||||
|
|
@ -612,6 +967,7 @@ fun SharedPdfTextStyleControls(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@Composable
|
||||
fun SharedPdfAnnotationOverlay(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
|
|
@ -620,7 +976,10 @@ fun SharedPdfAnnotationOverlay(
|
|||
activeTool: PdfInkTool = PdfInkTool.PEN,
|
||||
activeStrokeColorArgb: Int = 0xFF1976D2.toInt(),
|
||||
activeStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
selectedAnnotationId: String? = null
|
||||
selectedAnnotationId: String? = null,
|
||||
eraserPosition: Offset? = null,
|
||||
showEraserIndicator: Boolean = false,
|
||||
eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth
|
||||
) {
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) return
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -628,22 +987,14 @@ fun SharedPdfAnnotationOverlay(
|
|||
Box(Modifier.fillMaxSize()) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
annotations.forEach { annotation ->
|
||||
val isSelected = annotation.matchesSelectedAnnotation(selectedAnnotationId)
|
||||
if (isSelected && annotation.kind == PdfAnnotationKind.INK) {
|
||||
SharedPdfInkRenderer.createRenderData(annotation, canvasSize)?.let { renderData ->
|
||||
drawInkRenderData(renderData, selectedOutline = true)
|
||||
}
|
||||
}
|
||||
|
||||
when (annotation.kind) {
|
||||
PdfAnnotationKind.HIGHLIGHT -> {
|
||||
val highlightBounds = annotation.boundsList.ifEmpty { listOfNotNull(annotation.bounds) }
|
||||
highlightBounds.forEach { bounds ->
|
||||
drawRect(
|
||||
color = Color(annotation.colorArgb),
|
||||
color = Color(annotation.colorArgb).copy(alpha = SharedPdfAndroidHighlightColors.RenderAlpha),
|
||||
topLeft = bounds.topLeft(canvasSize),
|
||||
size = bounds.size(canvasSize),
|
||||
blendMode = BlendMode.Multiply
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -662,19 +1013,9 @@ fun SharedPdfAnnotationOverlay(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected && annotation.kind != PdfAnnotationKind.INK) {
|
||||
val bounds = annotation.bounds ?: annotation.boundsList.firstOrNull() ?: return@forEach
|
||||
drawRect(
|
||||
color = Color(0xFF64B5F6),
|
||||
topLeft = bounds.topLeft(canvasSize),
|
||||
size = bounds.size(canvasSize),
|
||||
style = Stroke(width = 2f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeStroke.size > 1) {
|
||||
if (activeStroke.isNotEmpty()) {
|
||||
val activeAnnotation = SharedPdfAnnotation(
|
||||
id = "active",
|
||||
pageIndex = 0,
|
||||
|
|
@ -686,6 +1027,22 @@ fun SharedPdfAnnotationOverlay(
|
|||
)
|
||||
SharedPdfInkRenderer.createRenderData(activeAnnotation, canvasSize)?.let(::drawInkRenderData)
|
||||
}
|
||||
|
||||
if (showEraserIndicator && eraserPosition != null) {
|
||||
val radius = SharedPdfInkRenderer.effectiveStrokeWidthPx(eraserStrokeWidth, canvasSize)
|
||||
.coerceAtLeast(8.dp.toPx())
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = 0.3f),
|
||||
radius = radius,
|
||||
center = eraserPosition
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
radius = radius,
|
||||
center = eraserPosition,
|
||||
style = Stroke(width = 1.dp.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
annotations
|
||||
|
|
@ -696,17 +1053,18 @@ fun SharedPdfAnnotationOverlay(
|
|||
val topPx = bounds.top * canvasSize.height
|
||||
val widthPx = ((bounds.right - bounds.left) * canvasSize.width).coerceAtLeast(24f)
|
||||
val heightPx = ((bounds.bottom - bounds.top) * canvasSize.height).coerceAtLeast(18f)
|
||||
val fontSizePx = annotation.sharedPdfTextFontSizePx(canvasSize)
|
||||
Text(
|
||||
text = annotation.text,
|
||||
color = Color(annotation.colorArgb),
|
||||
fontSize = annotation.fontSize.sp,
|
||||
lineHeight = (annotation.fontSize * 1.25f).sp,
|
||||
fontSize = with(density) { fontSizePx.toSp() },
|
||||
lineHeight = with(density) { (fontSizePx * 1.25f).toSp() },
|
||||
fontWeight = if (annotation.isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (annotation.isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
fontFamily = annotation.sharedPdfTextFontFamily(),
|
||||
textDecoration = annotation.textDecoration,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = SharedPdfTextAnnotationDefaults.estimateLineCount(annotation.text, annotation.fontSize, widthPx),
|
||||
maxLines = SharedPdfTextAnnotationDefaults.estimateLineCount(annotation.text, fontSizePx, widthPx),
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(leftPx.roundToInt(), topPx.roundToInt()) }
|
||||
.width(with(density) { widthPx.toDp() })
|
||||
|
|
@ -942,6 +1300,7 @@ private fun SharedPdfPenIcon(
|
|||
drawMatteCylinder(animatedBodyColor, collarRect)
|
||||
drawMarkerHead(animatedBodyColor, tipRect)
|
||||
}
|
||||
PdfInkTool.NONE,
|
||||
PdfInkTool.TEXT,
|
||||
PdfInkTool.ERASER -> Unit
|
||||
}
|
||||
|
|
@ -1298,14 +1657,13 @@ private fun DrawScope.drawInkPreview(
|
|||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfAnnotation.matchesSelectedAnnotation(selectedAnnotationId: String?): Boolean {
|
||||
if (selectedAnnotationId == null) return false
|
||||
return id == selectedAnnotationId || id.startsWith("${selectedAnnotationId}_line_")
|
||||
}
|
||||
|
||||
private val PdfInkTool.isHighlighter: Boolean
|
||||
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
|
||||
private fun Int.withSharedPdfAnnotationAlpha(alpha: Float): Int {
|
||||
return Color(this).copy(alpha = alpha.coerceIn(0f, 1f)).toArgb()
|
||||
}
|
||||
|
||||
private val SharedPdfAnnotation.textDecoration: TextDecoration
|
||||
get() {
|
||||
val decorations = mutableListOf<TextDecoration>()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,55 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal data class SharedReaderModalAnchorBounds(
|
||||
val leftPx: Float,
|
||||
val topPx: Float,
|
||||
val widthPx: Float,
|
||||
val heightPx: Float
|
||||
)
|
||||
|
||||
internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf<SharedReaderModalAnchorBounds?> { null }
|
||||
|
||||
internal enum class SharedReaderModalLevel {
|
||||
Panel,
|
||||
Popup
|
||||
}
|
||||
|
||||
val SharedReaderPopupDefaultMaxWidth = 440.dp
|
||||
private val SharedReaderPopupMinWidth = 320.dp
|
||||
private const val SharedReaderPopupWidthFraction = 0.58f
|
||||
|
||||
fun sharedReaderPopupWidth(
|
||||
availableWidth: Dp,
|
||||
maxWidth: Dp = SharedReaderPopupDefaultMaxWidth,
|
||||
minWidth: Dp = SharedReaderPopupMinWidth,
|
||||
widthFraction: Float = SharedReaderPopupWidthFraction
|
||||
): Dp {
|
||||
if (availableWidth <= 0.dp) return 0.dp
|
||||
val lowerBound = minWidth.coerceAtMost(availableWidth)
|
||||
val upperBound = maxWidth.coerceAtMost(availableWidth).coerceAtLeast(lowerBound)
|
||||
return (availableWidth * widthFraction.coerceIn(0f, 1f)).coerceIn(lowerBound, upperBound)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal expect fun SharedReaderModalLayer(
|
||||
onDismiss: () -> Unit,
|
||||
level: SharedReaderModalLevel = SharedReaderModalLevel.Popup,
|
||||
content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedReaderPopupLayer(
|
||||
onDismiss: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedReaderModalLayer(
|
||||
onDismiss = onDismiss,
|
||||
level = SharedReaderModalLevel.Popup,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private data class SharedScrollbarState(
|
||||
val progress: Float,
|
||||
val preferredThumbHeightPx: Float,
|
||||
val contentHeightPx: Float,
|
||||
val viewportHeightPx: Float
|
||||
)
|
||||
|
||||
private data class SharedPdfScrollbarState(
|
||||
val progress: Float,
|
||||
val contentHeightPx: Float,
|
||||
val viewportHeightPx: Float
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedReaderVerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollbarState by remember(listState) {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
if (totalItems == 0 || visibleItems.isEmpty() || viewportHeight <= 0f) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
|
||||
val averageItemHeight = visibleItems.sumOf { it.size }.toFloat() / visibleItems.size
|
||||
val contentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||
val viewportRatio = viewportHeight / contentHeight
|
||||
if (viewportRatio >= 1f) return@derivedStateOf null
|
||||
|
||||
val maxThumbHeight = viewportHeight / 2f
|
||||
val minThumbHeight = minOf(80f, maxThumbHeight)
|
||||
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(minThumbHeight, maxThumbHeight)
|
||||
val currentScroll = (listState.firstVisibleItemIndex * averageItemHeight) +
|
||||
listState.firstVisibleItemScrollOffset
|
||||
val maxScroll = contentHeight - viewportHeight
|
||||
val progress = (currentScroll / maxScroll).coerceIn(0f, 1f)
|
||||
|
||||
SharedScrollbarState(
|
||||
progress = progress,
|
||||
preferredThumbHeightPx = thumbHeight,
|
||||
contentHeightPx = contentHeight,
|
||||
viewportHeightPx = viewportHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val state = scrollbarState ?: return
|
||||
val density = LocalDensity.current
|
||||
var isDraggingScrollbar by remember { mutableStateOf(false) }
|
||||
var scrollbarVisible by remember { mutableStateOf(false) }
|
||||
var scrollInteractionTick by remember { mutableIntStateOf(0) }
|
||||
var scrollbarTrackHeight by remember { mutableStateOf(0f) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
var previousIndex = listState.firstVisibleItemIndex
|
||||
var previousOffset = listState.firstVisibleItemScrollOffset
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (index != previousIndex || abs(offset - previousOffset) > 1) {
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
previousIndex = index
|
||||
previousOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollInteractionTick, isDraggingScrollbar) {
|
||||
if (isDraggingScrollbar) {
|
||||
scrollbarVisible = true
|
||||
} else if (scrollInteractionTick > 0) {
|
||||
scrollbarVisible = true
|
||||
delay(5_000)
|
||||
scrollbarVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
val scrollbarAlpha by animateFloatAsState(
|
||||
targetValue = if (scrollbarVisible || isDraggingScrollbar) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "sharedReaderScrollbarAlpha"
|
||||
)
|
||||
val activeThemeColor = MaterialTheme.colorScheme.primary
|
||||
val idleColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f)
|
||||
val barColor by animateColorAsState(
|
||||
targetValue = if (isDraggingScrollbar) activeThemeColor else idleColor,
|
||||
label = "sharedReaderScrollbarColor"
|
||||
)
|
||||
val scrollbarIdleWidth = 4.dp
|
||||
val scrollbarActiveWidth = 8.dp
|
||||
val barWidth by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveWidth else scrollbarIdleWidth,
|
||||
label = "sharedReaderScrollbarWidth"
|
||||
)
|
||||
val preferredThumbHeight = with(density) { state.preferredThumbHeightPx.toDp() }
|
||||
val scrollbarIdleHeight = preferredThumbHeight.coerceAtLeast(40.dp)
|
||||
val scrollbarActiveHeight = preferredThumbHeight.coerceAtLeast(60.dp)
|
||||
val barHeight by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedReaderScrollbarHeight"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(36.dp)
|
||||
.padding(top = 8.dp, bottom = 8.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f)
|
||||
val thumbY = (availableSpace * state.progress).coerceIn(0f, availableSpace)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(x = 0, y = thumbY.roundToInt()) }
|
||||
.align(Alignment.TopEnd)
|
||||
.alpha(scrollbarAlpha)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(36.dp)
|
||||
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
val deltaY = change.position.y - change.previousPosition.y
|
||||
if (deltaY != 0f) {
|
||||
change.consume()
|
||||
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: state.viewportHeightPx
|
||||
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
|
||||
.coerceAtLeast(1f)
|
||||
val scrollDelta = (deltaY / trackSpace) *
|
||||
(state.contentHeightPx - state.viewportHeightPx)
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = barWidth, height = barHeight)
|
||||
.background(barColor, RoundedCornerShape(999.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfVerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
pageCount: Int,
|
||||
currentPage: Int,
|
||||
isDarkMode: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollbarState by remember(listState) {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
if (totalItems == 0 || visibleItems.isEmpty() || viewportHeight <= 0f) {
|
||||
return@derivedStateOf null
|
||||
}
|
||||
|
||||
val averageItemHeight = visibleItems.sumOf { it.size }.toFloat() / visibleItems.size
|
||||
val contentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
|
||||
val maxScroll = contentHeight - viewportHeight
|
||||
if (maxScroll <= 1f) return@derivedStateOf null
|
||||
|
||||
val currentScroll = (listState.firstVisibleItemIndex * averageItemHeight) +
|
||||
listState.firstVisibleItemScrollOffset
|
||||
SharedPdfScrollbarState(
|
||||
progress = (currentScroll / maxScroll).coerceIn(0f, 1f),
|
||||
contentHeightPx = contentHeight,
|
||||
viewportHeightPx = viewportHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val state = scrollbarState ?: return
|
||||
val density = LocalDensity.current
|
||||
var isDraggingScrollbar by remember { mutableStateOf(false) }
|
||||
var scrollbarVisible by remember { mutableStateOf(false) }
|
||||
var scrollInteractionTick by remember { mutableIntStateOf(0) }
|
||||
var scrollbarTrackHeight by remember { mutableStateOf(0f) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
var previousIndex = listState.firstVisibleItemIndex
|
||||
var previousOffset = listState.firstVisibleItemScrollOffset
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (index != previousIndex || abs(offset - previousOffset) > 1) {
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
previousIndex = index
|
||||
previousOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollInteractionTick, isDraggingScrollbar) {
|
||||
if (isDraggingScrollbar) {
|
||||
scrollbarVisible = true
|
||||
} else if (scrollInteractionTick > 0) {
|
||||
scrollbarVisible = true
|
||||
delay(5_000)
|
||||
scrollbarVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
val scrollbarAlpha by animateFloatAsState(
|
||||
targetValue = if (scrollbarVisible || isDraggingScrollbar) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "sharedPdfScrollbarAlpha"
|
||||
)
|
||||
val activeThemeColor = if (isDarkMode) Color(0xFF1976D2) else Color(0xFF4285F4)
|
||||
val idleColor = if (isDarkMode) Color.Gray else Color.DarkGray
|
||||
val barColor by animateColorAsState(
|
||||
targetValue = if (isDraggingScrollbar) activeThemeColor else idleColor,
|
||||
label = "sharedPdfScrollbarColor"
|
||||
)
|
||||
val scrollbarIdleWidth = 4.dp
|
||||
val scrollbarActiveWidth = 8.dp
|
||||
val barWidth by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveWidth else scrollbarIdleWidth,
|
||||
label = "sharedPdfScrollbarWidth"
|
||||
)
|
||||
val scrollbarIdleHeight = 40.dp
|
||||
val scrollbarActiveHeight = 60.dp
|
||||
val barHeight by animateDpAsState(
|
||||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedPdfScrollbarHeight"
|
||||
)
|
||||
val safeCurrentPage = if (pageCount > 0) currentPage.coerceIn(0, pageCount - 1) else 0
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(48.dp)
|
||||
.padding(top = 12.dp, bottom = 12.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
val availableSpace = (effectiveTrackHeight - thumbHeightPx).coerceAtLeast(0f)
|
||||
val thumbY = (availableSpace * state.progress).coerceIn(0f, availableSpace)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(x = 0, y = thumbY.roundToInt()) }
|
||||
.align(Alignment.TopEnd)
|
||||
.wrapContentSize(align = Alignment.CenterEnd, unbounded = true)
|
||||
.alpha(scrollbarAlpha)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AnimatedVisibility(
|
||||
visible = isDraggingScrollbar && pageCount > 0,
|
||||
enter = fadeIn() + slideInHorizontally { it / 2 },
|
||||
exit = fadeOut() + slideOutHorizontally { it / 2 }
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = activeThemeColor,
|
||||
shadowElevation = 4.dp,
|
||||
modifier = Modifier.padding(end = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${safeCurrentPage + 1}/$pageCount",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(48.dp)
|
||||
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
val deltaY = change.position.y - change.previousPosition.y
|
||||
if (deltaY != 0f) {
|
||||
change.consume()
|
||||
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: state.viewportHeightPx
|
||||
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
|
||||
.coerceAtLeast(1f)
|
||||
val scrollDelta = (deltaY / trackSpace) *
|
||||
(state.contentHeightPx - state.viewportHeightPx)
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = barWidth, height = barHeight)
|
||||
.background(barColor, RoundedCornerShape(999.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.sharedAcceleratedLazyWheelScroll(
|
||||
listState: LazyListState,
|
||||
multiplier: Float = 4f
|
||||
): Modifier {
|
||||
val safeMultiplier = multiplier.coerceIn(1f, 12f)
|
||||
return pointerInput(listState, safeMultiplier) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (event.type != PointerEventType.Scroll) continue
|
||||
val scrollDelta = event.changes.fold(0f) { total, change ->
|
||||
val delta = change.scrollDelta
|
||||
total + if (abs(delta.y) >= abs(delta.x)) delta.y else delta.x
|
||||
}
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
val adaptiveMultiplier = when {
|
||||
abs(scrollDelta) < 1f -> 24f
|
||||
abs(scrollDelta) < 8f -> 10f
|
||||
else -> safeMultiplier
|
||||
}
|
||||
listState.dispatchRawDelta(scrollDelta * (adaptiveMultiplier - 1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,723 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.ReaderAction
|
||||
import com.aryan.reader.shared.ReaderToolbarPreferences
|
||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||
import com.aryan.reader.shared.SharedSettingsAction
|
||||
import com.aryan.reader.shared.SharedSettingsCategoryModel
|
||||
import com.aryan.reader.shared.SharedSettingsDestination
|
||||
import com.aryan.reader.shared.SharedSettingsHubModel
|
||||
import com.aryan.reader.shared.SharedSettingsItemKind
|
||||
import com.aryan.reader.shared.SharedSettingsItemModel
|
||||
import com.aryan.reader.shared.SharedSettingsPageKind
|
||||
import com.aryan.reader.shared.SharedSettingsPageModel
|
||||
import com.aryan.reader.shared.SharedSettingsSearchResult
|
||||
import com.aryan.reader.shared.parentDestination
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
@Composable
|
||||
fun SharedSettingsHub(
|
||||
model: SharedSettingsHubModel,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
readerDefaultSettings: ReaderSettings,
|
||||
onReaderDefaultSettingsChange: (ReaderSettings) -> Unit,
|
||||
pdfReaderDefaultSettings: ReaderSettings = readerDefaultSettings,
|
||||
onPdfReaderDefaultSettingsChange: (ReaderSettings) -> Unit = onReaderDefaultSettingsChange,
|
||||
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
readerToolbarPreferences: ReaderToolbarPreferences? = null,
|
||||
onReaderToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit = {},
|
||||
customFonts: List<CustomFontItem> = emptyList(),
|
||||
onPickCustomFont: (() -> String?)? = null,
|
||||
readerCustomTextureIds: List<String> = emptyList(),
|
||||
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null,
|
||||
showTopBar: Boolean = true,
|
||||
onBack: (() -> Unit)? = null,
|
||||
destination: SharedSettingsDestination = SharedSettingsDestination.ROOT,
|
||||
onDestinationChange: (SharedSettingsDestination) -> Unit = {},
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp)
|
||||
) {
|
||||
val page = remember(model, destination) { model.page(destination) }
|
||||
val searchResults = remember(model, query) { model.searchResults(query) }
|
||||
|
||||
fun navigateTo(next: SharedSettingsDestination) {
|
||||
onQueryChange("")
|
||||
onDestinationChange(next)
|
||||
}
|
||||
|
||||
fun navigateUp() {
|
||||
if (query.isNotBlank()) {
|
||||
onQueryChange("")
|
||||
return
|
||||
}
|
||||
val parent = destination.parentDestination()
|
||||
if (parent != null) {
|
||||
onDestinationChange(parent)
|
||||
} else {
|
||||
onBack?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
val contentWidth = if (maxWidth >= 960.dp) Modifier.width(860.dp) else Modifier.fillMaxWidth()
|
||||
Column(
|
||||
modifier = contentWidth
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
if (showTopBar) {
|
||||
SharedSettingsHeader(
|
||||
page = page,
|
||||
canNavigateUp = query.isNotBlank() || destination != SharedSettingsDestination.ROOT || onBack != null,
|
||||
onNavigateUp = ::navigateUp
|
||||
)
|
||||
}
|
||||
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
label = { Text("Search settings") }
|
||||
)
|
||||
|
||||
when {
|
||||
query.isNotBlank() -> SharedSettingsSearchResults(
|
||||
results = searchResults,
|
||||
onNavigate = ::navigateTo,
|
||||
onAction = onAction,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
page.kind == SharedSettingsPageKind.ROOT -> SharedSettingsCategoryList(
|
||||
categories = page.categories,
|
||||
onNavigate = ::navigateTo,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
page.kind == SharedSettingsPageKind.CATEGORY -> SharedSettingsItemList(
|
||||
page = page,
|
||||
onNavigate = ::navigateTo,
|
||||
onAction = onAction,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
else -> SharedSettingsDetailPage(
|
||||
page = page,
|
||||
settings = readerDefaultSettings,
|
||||
onSettingsChange = onReaderDefaultSettingsChange,
|
||||
pdfSettings = pdfReaderDefaultSettings,
|
||||
onPdfSettingsChange = onPdfReaderDefaultSettingsChange,
|
||||
toolbarPreferences = readerToolbarPreferences,
|
||||
onToolbarPreferencesChange = onReaderToolbarPreferencesChange,
|
||||
ttsReplacementPreferences = ttsReplacementPreferences,
|
||||
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
|
||||
customFonts = customFonts,
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
readerCustomTextureIds = readerCustomTextureIds,
|
||||
onImportReaderTexture = onImportReaderTexture,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsHeader(
|
||||
page: SharedSettingsPageModel,
|
||||
canNavigateUp: Boolean,
|
||||
onNavigateUp: () -> Unit
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (canNavigateUp) {
|
||||
TextButton(onClick = onNavigateUp) {
|
||||
Text("Back")
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(page.title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
page.summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsCategoryList(
|
||||
categories: List<SharedSettingsCategoryModel>,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
categories.forEach { category ->
|
||||
item(key = category.destination.name) {
|
||||
SharedSettingsCategoryRow(category = category, onNavigate = onNavigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsCategoryRow(
|
||||
category: SharedSettingsCategoryModel,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
|
||||
onClick = { onNavigate(category.destination) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Icon(
|
||||
category.destination.iconForSettingsDestination(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(category.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
category.summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
if (category.itemCount == 1) "1 setting" else "${category.itemCount} settings",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsItemList(
|
||||
page: SharedSettingsPageModel,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
item(key = page.destination.name) {
|
||||
SharedSettingsGroup {
|
||||
page.items.forEachIndexed { index, item ->
|
||||
SharedSettingsRow(
|
||||
item = item,
|
||||
onNavigate = onNavigate,
|
||||
onAction = onAction
|
||||
)
|
||||
if (index != page.items.lastIndex) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsSearchResults(
|
||||
results: List<SharedSettingsSearchResult>,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (results.isEmpty()) {
|
||||
SharedSettingsEmptySearch(modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
item {
|
||||
SharedSettingsGroup {
|
||||
results.forEachIndexed { index, result ->
|
||||
SharedSettingsSearchResultRow(
|
||||
result = result,
|
||||
onNavigate = onNavigate,
|
||||
onAction = onAction
|
||||
)
|
||||
if (index != results.lastIndex) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsEmptySearch(modifier: Modifier) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||
Text("No settings found", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsSearchResultRow(
|
||||
result: SharedSettingsSearchResult,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit
|
||||
) {
|
||||
val contentColor = if (result.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.46f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
enabled = result.enabled,
|
||||
onClick = {
|
||||
result.destination?.let(onNavigate) ?: result.action?.let(onAction)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
result.action?.iconForSettings() ?: result.destination?.iconForSettingsDestination() ?: Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (result.kind == SharedSettingsItemKind.DESTRUCTIVE) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(result.title, fontWeight = FontWeight.SemiBold, color = contentColor)
|
||||
Text(
|
||||
result.breadcrumb,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
result.summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (result.enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.48f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
when (result.kind) {
|
||||
SharedSettingsItemKind.TOGGLE -> {
|
||||
Switch(
|
||||
checked = result.checked == true,
|
||||
enabled = result.enabled,
|
||||
onCheckedChange = { result.action?.let(onAction) }
|
||||
)
|
||||
}
|
||||
else -> Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsGroup(content: @Composable () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsRow(
|
||||
item: SharedSettingsItemModel,
|
||||
onNavigate: (SharedSettingsDestination) -> Unit,
|
||||
onAction: (SharedSettingsAction) -> Unit
|
||||
) {
|
||||
val contentColor = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.46f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
enabled = item.enabled,
|
||||
onClick = {
|
||||
item.destination?.let(onNavigate) ?: onAction(item.action)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 2.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
item.action.iconForSettings(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.kind == SharedSettingsItemKind.DESTRUCTIVE) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(item.title, fontWeight = FontWeight.SemiBold, color = contentColor)
|
||||
Text(
|
||||
item.summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (item.enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.48f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
when (item.kind) {
|
||||
SharedSettingsItemKind.TOGGLE -> {
|
||||
Switch(
|
||||
checked = item.checked == true,
|
||||
enabled = item.enabled,
|
||||
onCheckedChange = { onAction(item.action) }
|
||||
)
|
||||
}
|
||||
SharedSettingsItemKind.INFO -> Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
SharedSettingsItemKind.DESTRUCTIVE,
|
||||
SharedSettingsItemKind.NAVIGATION,
|
||||
SharedSettingsItemKind.CONTROL -> Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsDetailPage(
|
||||
page: SharedSettingsPageModel,
|
||||
settings: ReaderSettings,
|
||||
onSettingsChange: (ReaderSettings) -> Unit,
|
||||
pdfSettings: ReaderSettings,
|
||||
onPdfSettingsChange: (ReaderSettings) -> Unit,
|
||||
toolbarPreferences: ReaderToolbarPreferences?,
|
||||
onToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit,
|
||||
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
|
||||
customFonts: List<CustomFontItem>,
|
||||
onPickCustomFont: (() -> String?)?,
|
||||
readerCustomTextureIds: List<String>,
|
||||
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
page.localOverrideNote?.let { note ->
|
||||
SharedSettingsLocalOverrideNote(note)
|
||||
}
|
||||
|
||||
SharedSettingsDetailSurface {
|
||||
when (page.destination) {
|
||||
SharedSettingsDestination.EPUB_FORMAT -> {
|
||||
SharedReaderFormatControls(
|
||||
settings = settings,
|
||||
toolbarPreferences = ReaderToolbarPreferences(),
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
customFonts = customFonts,
|
||||
onReaderAction = { action ->
|
||||
if (action is ReaderAction.SettingsChanged) onSettingsChange(action.settings)
|
||||
}
|
||||
)
|
||||
}
|
||||
SharedSettingsDestination.EPUB_THEME_TEXTURE -> {
|
||||
SharedReaderThemeControls(
|
||||
settings = settings,
|
||||
customTextureIds = readerCustomTextureIds,
|
||||
onImportTexture = onImportReaderTexture,
|
||||
onSettingsChange = onSettingsChange
|
||||
)
|
||||
}
|
||||
SharedSettingsDestination.EPUB_VISUAL_DEFAULTS -> {
|
||||
SharedReaderVisualOptionsControls(
|
||||
settings = settings,
|
||||
onReaderAction = { action ->
|
||||
if (action is ReaderAction.SettingsChanged) onSettingsChange(action.settings)
|
||||
}
|
||||
)
|
||||
}
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Fixed-layout appearance", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
SharedReaderThemeControls(
|
||||
settings = pdfSettings,
|
||||
builtInThemes = BuiltInPdfReaderThemes,
|
||||
customTextureIds = readerCustomTextureIds,
|
||||
onImportTexture = onImportReaderTexture,
|
||||
onSettingsChange = onPdfSettingsChange
|
||||
)
|
||||
HorizontalDivider()
|
||||
Text("Visual options", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = "Remove gap between pages",
|
||||
summary = "Applies to vertical PDF reading mode.",
|
||||
checked = !pdfSettings.pdfVerticalPageGapVisible,
|
||||
onCheckedChange = { removeGap ->
|
||||
onPdfSettingsChange(pdfSettings.copy(pdfVerticalPageGapVisible = !removeGap))
|
||||
}
|
||||
)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = "Hide page number overlay",
|
||||
summary = "Removes the small page count label from each PDF page.",
|
||||
checked = !pdfSettings.pdfPageNumberOverlayVisible,
|
||||
onCheckedChange = { hideOverlay ->
|
||||
onPdfSettingsChange(pdfSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSettingsDestination.PDF_READER_TOOLS -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Reader-managed PDF tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS -> {
|
||||
if (toolbarPreferences == null) {
|
||||
Text("Reader toolbar defaults are managed from the reader on this platform.")
|
||||
} else {
|
||||
SharedReaderToolbarControls(
|
||||
toolbarPreferences = toolbarPreferences,
|
||||
onToolbarPreferencesChange = onToolbarPreferencesChange
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSettingsDestination.EPUB_TTS_REPLACEMENTS,
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> {
|
||||
SharedReaderTtsReplacementControls(
|
||||
preferences = ttsReplacementPreferences,
|
||||
bookId = "global",
|
||||
onPreferencesChange = onTtsReplacementPreferencesChange,
|
||||
allowBookScope = false
|
||||
)
|
||||
}
|
||||
else -> Text(page.summary, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsDetailSurface(content: @Composable () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSettingsLocalOverrideNote(note: SharedSettingsItemModel) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.42f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Info, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(note.title, fontWeight = FontWeight.SemiBold)
|
||||
Text(note.summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedPdfVisualOptionDefaultsSwitch(
|
||||
title: String,
|
||||
summary: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(title, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSettingsDestination.iconForSettingsDestination(): ImageVector {
|
||||
return when (this) {
|
||||
SharedSettingsDestination.EPUB_TEXT,
|
||||
SharedSettingsDestination.EPUB_FORMAT,
|
||||
SharedSettingsDestination.EPUB_TTS_REPLACEMENTS,
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS -> Icons.Default.TextFields
|
||||
SharedSettingsDestination.PDF_COMICS,
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS,
|
||||
SharedSettingsDestination.PDF_READER_TOOLS,
|
||||
SharedSettingsDestination.EPUB_THEME_TEXTURE,
|
||||
SharedSettingsDestination.EPUB_VISUAL_DEFAULTS,
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS,
|
||||
SharedSettingsDestination.THEME_APPEARANCE -> Icons.Default.Palette
|
||||
SharedSettingsDestination.TTS_AI -> Icons.Default.Settings
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE -> Icons.Default.Folder
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS -> Icons.Default.Cloud
|
||||
SharedSettingsDestination.EXTRA -> Icons.Default.Settings
|
||||
SharedSettingsDestination.HELP_ABOUT -> Icons.Default.Info
|
||||
SharedSettingsDestination.ROOT -> Icons.Default.Settings
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSettingsAction.iconForSettings(): ImageVector {
|
||||
return when (this) {
|
||||
SharedSettingsAction.TEXT_READER_DEFAULTS,
|
||||
SharedSettingsAction.TTS_REPLACEMENTS,
|
||||
SharedSettingsAction.TTS_SETTINGS,
|
||||
SharedSettingsAction.HIDE_READER_AI -> Icons.Default.TextFields
|
||||
SharedSettingsAction.PDF_READER_DEFAULTS,
|
||||
SharedSettingsAction.READER_TOOLBAR,
|
||||
SharedSettingsAction.APP_THEME -> Icons.Default.Palette
|
||||
SharedSettingsAction.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedSettingsAction.SIGN_IN,
|
||||
SharedSettingsAction.SIGN_OUT,
|
||||
SharedSettingsAction.CLOUD_SYNC -> Icons.Default.Cloud
|
||||
SharedSettingsAction.FOLDER_SYNC -> Icons.Default.Folder
|
||||
SharedSettingsAction.CLEAR_BOOK_CACHE,
|
||||
SharedSettingsAction.CLEAR_REFLOW_CACHE,
|
||||
SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA -> Icons.Default.Delete
|
||||
SharedSettingsAction.HELP_FEEDBACK -> Icons.Default.Feedback
|
||||
SharedSettingsAction.SUPPORT -> Icons.Default.Favorite
|
||||
SharedSettingsAction.LOCAL_OVERRIDE_NOTE,
|
||||
SharedSettingsAction.ABOUT -> Icons.Default.Info
|
||||
SharedSettingsAction.EXPORT_LOGS,
|
||||
SharedSettingsAction.DEBUG_ACTIONS,
|
||||
SharedSettingsAction.TEST_PANEL_DETECTION,
|
||||
SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION,
|
||||
SharedSettingsAction.DEVICE_MANAGEMENT,
|
||||
SharedSettingsAction.AI_SETTINGS,
|
||||
SharedSettingsAction.LANGUAGE,
|
||||
SharedSettingsAction.TABS_TOGGLE,
|
||||
SharedSettingsAction.RECENT_LIMIT,
|
||||
SharedSettingsAction.STRICT_FILE_FILTER,
|
||||
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
|
||||
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> Icons.Default.Settings
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
@Composable
|
||||
fun SharedStableOutlinedTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
supportingText: @Composable (() -> Unit)? = null,
|
||||
isError: Boolean = false,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
singleLine: Boolean = false,
|
||||
minLines: Int = 1,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
shape: Shape = OutlinedTextFieldDefaults.shape,
|
||||
selectionKey: Any? = Unit
|
||||
) {
|
||||
var fieldValue by remember(selectionKey) {
|
||||
mutableStateOf(value.toTextFieldValueWithCursorAtEnd())
|
||||
}
|
||||
|
||||
LaunchedEffect(selectionKey, value) {
|
||||
if (value != fieldValue.text) {
|
||||
fieldValue = value.toTextFieldValueWithCursorAtEnd()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { nextValue ->
|
||||
fieldValue = nextValue
|
||||
if (nextValue.text != value) {
|
||||
onValueChange(nextValue.text)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
label = label,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
supportingText = supportingText,
|
||||
isError = isError,
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
maxLines = if (singleLine) 1 else maxLines.coerceAtLeast(minLines),
|
||||
shape = shape
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.toTextFieldValueWithCursorAtEnd(): TextFieldValue {
|
||||
return TextFieldValue(
|
||||
text = this,
|
||||
selection = TextRange(length)
|
||||
)
|
||||
}
|
||||
|
|
@ -43,7 +43,6 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -207,7 +206,7 @@ private fun SharedGoogleFontsDialog(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
SharedStableOutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -435,8 +434,8 @@ fun SharedSupportProjectScreen(
|
|||
fun SharedAboutScreen(
|
||||
versionName: String,
|
||||
buildLabel: String,
|
||||
onOpenSource: () -> Unit,
|
||||
onOpenIssues: () -> Unit,
|
||||
onOpenSource: (() -> Unit)? = null,
|
||||
onOpenIssues: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
|
|
@ -471,18 +470,22 @@ fun SharedAboutScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
SharedUtilityOptionCard(
|
||||
title = "Source Code",
|
||||
body = "Browse the project source on GitHub.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenSource
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Issues",
|
||||
body = "Open the issue tracker for bugs and feature requests.",
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenIssues
|
||||
)
|
||||
if (onOpenSource != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = "Source Code",
|
||||
body = "Browse the project source on GitHub.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenSource
|
||||
)
|
||||
}
|
||||
if (onOpenIssues != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = "Issues",
|
||||
body = "Open the issue tracker for bugs and feature requests.",
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenIssues
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue