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:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -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

View file

@ -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()
)

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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(

View file

@ -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 {

View file

@ -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()

View file

@ -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
)
}

View file

@ -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(

View file

@ -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>

View file

@ -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 {

View file

@ -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
)
}

View file

@ -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
)
}
}

View file

@ -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
}

View file

@ -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)

View file

@ -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

View file

@ -76,6 +76,7 @@ object SharedOpdsDownloadNamer {
return when (acquisition.formatName) {
"EPUB" -> ".epub"
"PDF" -> ".pdf"
"PPTX" -> ".pptx"
"MOBI" -> ".mobi"
"FB2" -> ".fb2"
"CBZ" -> ".cbz"

View file

@ -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,

View file

@ -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)

View file

@ -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
)
}

View file

@ -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()
)
}

View file

@ -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

View file

@ -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(

View file

@ -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)

View file

@ -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 }
}

View file

@ -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("|")
}

View file

@ -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
}

View file

@ -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()}")
}
}

View file

@ -7,5 +7,5 @@ import androidx.compose.ui.Modifier
internal expect fun LocalBookCoverImage(
path: String,
contentDescription: String?,
modifier: Modifier = Modifier
modifier: Modifier
)

View file

@ -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 }

View file

@ -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
}

View file

@ -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)
)
}
}
}

View file

@ -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,

View file

@ -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
)
}
}
}
}

View file

@ -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

View file

@ -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)

View file

@ -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("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.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()
}
}

View file

@ -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)
}
}

View file

@ -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>()

View file

@ -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
)
}

View file

@ -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))
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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)
)
}

View file

@ -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
)
}
}
}