Windows (#291)
* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
This commit is contained in:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -178,7 +178,8 @@ object CssParser {
|
|||
constraints: Constraints,
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: Color = Color.Unspecified,
|
||||
themeTextColor: Color = Color.Unspecified
|
||||
themeTextColor: Color = Color.Unspecified,
|
||||
adaptThemeColors: Boolean = true
|
||||
): OptimizedCssParseResult {
|
||||
val byTag = mutableMapOf<String, MutableList<CssRule>>()
|
||||
val byClass = mutableMapOf<String, MutableList<CssRule>>()
|
||||
|
|
@ -193,7 +194,7 @@ object CssParser {
|
|||
val mediaQueryRegex = Regex("@media[^{]+\\{((?>[^{}]+|\\{[^{}]*\\})*)\\}")
|
||||
mediaQueryRegex.findAll(cleanedCss).forEach { match ->
|
||||
val condition = match.groups[0]?.value?.trim() ?: ""
|
||||
if (isDarkTheme && condition.contains("prefers-color-scheme: dark")) {
|
||||
if (adaptThemeColors && isDarkTheme && condition.contains("prefers-color-scheme: dark")) {
|
||||
val darkCss = match.groups[1]?.value ?: ""
|
||||
cleanedCss += "\n$darkCss"
|
||||
}
|
||||
|
|
@ -231,12 +232,26 @@ object CssParser {
|
|||
}
|
||||
val specificity = calculateSpecificity(originalSelector)
|
||||
val normalStyle = parseProperties(
|
||||
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false,
|
||||
isDarkTheme, themeBackgroundColor, themeTextColor
|
||||
properties = propertiesGroup,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
density = density,
|
||||
constraints = constraints,
|
||||
onlyImportant = false,
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor,
|
||||
adaptThemeColors = adaptThemeColors
|
||||
)
|
||||
val importantStyle = parseProperties(
|
||||
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true,
|
||||
isDarkTheme, themeBackgroundColor, themeTextColor
|
||||
properties = propertiesGroup,
|
||||
baseFontSizeSp = baseFontSizeSp,
|
||||
density = density,
|
||||
constraints = constraints,
|
||||
onlyImportant = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
themeBackgroundColor = themeBackgroundColor,
|
||||
themeTextColor = themeTextColor,
|
||||
adaptThemeColors = adaptThemeColors
|
||||
)
|
||||
|
||||
fun addRule(style: CssStyle, spec: Int) {
|
||||
|
|
@ -376,7 +391,8 @@ object CssParser {
|
|||
onlyImportant: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
themeBackgroundColor: Color = Color.Unspecified,
|
||||
themeTextColor: Color = Color.Unspecified
|
||||
themeTextColor: Color = Color.Unspecified,
|
||||
adaptThemeColors: Boolean = true
|
||||
): CssStyle {
|
||||
var spanStyle = SpanStyle()
|
||||
var paragraphStyle = ParagraphStyle()
|
||||
|
|
@ -451,6 +467,14 @@ object CssParser {
|
|||
var borderBottomRightRadius: Dp = 0.dp
|
||||
var borderBottomLeftRadius: Dp = 0.dp
|
||||
|
||||
fun maybeAdaptColor(color: Color, isBackground: Boolean): Color {
|
||||
return if (adaptThemeColors) {
|
||||
this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground, themeBackgroundColor, themeTextColor)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
splitDeclarations(properties).filter { it.isNotBlank() }.forEach { prop ->
|
||||
val parts = prop.split(':', limit = 2).map { it.trim() }
|
||||
if (parts.size == 2) {
|
||||
|
|
@ -473,7 +497,7 @@ object CssParser {
|
|||
styleStr: String?
|
||||
) {
|
||||
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
|
||||
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
|
||||
val parsedColor = colorStr?.let { parseColor(it) }?.let { maybeAdaptColor(it, isBackground = false) }
|
||||
|
||||
val isExplicitWidth = widthStr != null
|
||||
|
||||
|
|
@ -528,7 +552,7 @@ object CssParser {
|
|||
}
|
||||
"color" -> {
|
||||
parseColor(value)?.let {
|
||||
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor))
|
||||
spanStyle = spanStyle.copy(color = maybeAdaptColor(it, isBackground = false))
|
||||
}
|
||||
}
|
||||
"text-align" -> {
|
||||
|
|
@ -585,7 +609,7 @@ object CssParser {
|
|||
val styles = listOf("solid", "double", "dotted", "dashed", "wavy")
|
||||
parts.firstOrNull { it in styles }?.let { textDecorationStyle = it }
|
||||
parts.firstNotNullOfOrNull { parseColor(it) }?.let { color ->
|
||||
textDecorationColor = this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
textDecorationColor = maybeAdaptColor(color, isBackground = false)
|
||||
}
|
||||
}
|
||||
"word-spacing" -> {
|
||||
|
|
@ -601,7 +625,7 @@ object CssParser {
|
|||
}
|
||||
"text-decoration-color" -> {
|
||||
parseColor(value)?.let {
|
||||
textDecorationColor = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
textDecorationColor = maybeAdaptColor(it, isBackground = false)
|
||||
}
|
||||
}
|
||||
"text-underline-offset" -> {
|
||||
|
|
@ -661,7 +685,7 @@ object CssParser {
|
|||
|
||||
"background-color" -> {
|
||||
val originalColor = parseColor(value) ?: Color.Unspecified
|
||||
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
|
||||
backgroundColor = maybeAdaptColor(originalColor, isBackground = true)
|
||||
}
|
||||
|
||||
// Border Properties
|
||||
|
|
@ -801,7 +825,7 @@ object CssParser {
|
|||
textEmphasisStyleString = value
|
||||
}
|
||||
"text-emphasis-color", "-epub-text-emphasis-color" -> {
|
||||
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
|
||||
textEmphasisColor = parseColor(value)?.let { maybeAdaptColor(it, isBackground = false) }
|
||||
}
|
||||
"text-emphasis-position", "-epub-text-emphasis-position" -> {
|
||||
if (value in listOf("over", "under")) {
|
||||
|
|
@ -859,7 +883,7 @@ object CssParser {
|
|||
val finalStyle = style ?: "none"
|
||||
val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
|
||||
|
||||
val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
|
||||
val adaptedColor = maybeAdaptColor(finalColor, isBackground = false)
|
||||
|
||||
if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") {
|
||||
return BorderStyle(finalWidth, adaptedColor, finalStyle)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.ReaderSearchOptions
|
||||
|
||||
sealed interface LibraryAction {
|
||||
data class SearchChanged(val query: String) : LibraryAction
|
||||
data class SortChanged(val sortOrder: SortOrder) : LibraryAction
|
||||
|
|
@ -16,15 +20,36 @@ sealed interface ReaderAction {
|
|||
data object NextPage : ReaderAction
|
||||
data object PreviousPage : ReaderAction
|
||||
data class GoToPage(val pageIndex: Int) : ReaderAction
|
||||
data class GoToPageNumber(val pageNumber: Int) : ReaderAction
|
||||
data class GoToProgress(val progress: Float) : ReaderAction
|
||||
data class GoToChapter(val chapterIndex: Int) : ReaderAction
|
||||
data class GoToLocator(val locator: ReaderLocator) : ReaderAction
|
||||
data class VisiblePageChanged(val pageIndex: Int, val locator: ReaderLocator? = null) : ReaderAction
|
||||
data class GoToSearchResult(val resultIndex: Int) : ReaderAction
|
||||
data class SearchChanged(val query: String) : ReaderAction
|
||||
data object SearchOpened : ReaderAction
|
||||
data object SearchClosed : ReaderAction
|
||||
data object SearchResultsPanelToggled : ReaderAction
|
||||
data class SearchOptionsChanged(val options: ReaderSearchOptions) : ReaderAction
|
||||
data object NextSearchResult : ReaderAction
|
||||
data object PreviousSearchResult : ReaderAction
|
||||
data object ToggleBookmark : ReaderAction
|
||||
data class ToggleBookmarkAtLocator(
|
||||
val locator: ReaderLocator,
|
||||
val title: String? = null,
|
||||
val preview: String? = null
|
||||
) : ReaderAction
|
||||
data class SettingsChanged(val settings: ReaderSettings) : ReaderAction
|
||||
data class RenderModeChanged(val renderMode: RenderMode) : ReaderAction
|
||||
data class ThemeChanged(val theme: ReaderTheme) : ReaderAction
|
||||
data class FormatChanged(val settings: FormatSettings) : ReaderAction
|
||||
data class HighlightCreated(val highlight: UserHighlight) : ReaderAction
|
||||
data class HighlightUpdated(
|
||||
val highlightId: String,
|
||||
val color: HighlightColor? = null,
|
||||
val note: String? = null
|
||||
) : ReaderAction
|
||||
data class HighlightDeleted(val highlightId: String) : ReaderAction
|
||||
}
|
||||
|
||||
sealed interface AppAction {
|
||||
|
|
@ -33,6 +58,25 @@ sealed interface AppAction {
|
|||
data class NavigationRequested(val event: NavigationEvent) : AppAction
|
||||
data class AppThemeChanged(val mode: AppThemeMode) : AppAction
|
||||
data class AppContrastChanged(val option: AppContrastOption) : AppAction
|
||||
data class AppTextDimFactorLightChanged(val factor: Float) : AppAction
|
||||
data class AppTextDimFactorDarkChanged(val factor: Float) : AppAction
|
||||
data class AppSeedColorChanged(val color: Color?) : AppAction
|
||||
data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction
|
||||
data class CustomAppThemeDeleted(val themeId: String) : AppAction
|
||||
data class SyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class TabsEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class BookTabOpened(val bookId: String) : AppAction
|
||||
data class BookTabClosed(val bookId: String) : AppAction
|
||||
data object AllTabsClosed : AppAction
|
||||
data class HomePinToggled(val bookId: String) : AppAction
|
||||
data class LibraryPinToggled(val bookId: String) : 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 ReaderTtsReplacementPreferencesChanged(
|
||||
val preferences: ReaderTtsReplacementPreferences,
|
||||
) : AppAction
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,5 +118,8 @@ data class SharedReaderScreenState(
|
|||
val appSeedColor: Color? = null,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val allTags: List<Tag> = emptyList(),
|
||||
val showTagSelectionDialogFor: Set<String> = emptySet()
|
||||
val showTagSelectionDialogFor: Set<String> = emptySet(),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
data class CustomFontItem(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val fileName: String,
|
||||
val fileExtension: String,
|
||||
val path: String,
|
||||
val timestamp: Long,
|
||||
val isDeleted: Boolean = false
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
enum class ReaderPlatform {
|
||||
ANDROID,
|
||||
DESKTOP
|
||||
}
|
||||
|
||||
enum class ReaderFeatureSurface {
|
||||
PDF_VIEWER,
|
||||
EPUB_READER,
|
||||
TEXT_READER
|
||||
}
|
||||
|
||||
data class FileTypeCapability(
|
||||
val type: FileType,
|
||||
val displayName: String,
|
||||
val extensions: Set<String>,
|
||||
val androidSurface: ReaderFeatureSurface?,
|
||||
val desktopSurface: ReaderFeatureSurface?,
|
||||
val syncEligible: Boolean = true
|
||||
) {
|
||||
val isReadableOnAndroid: Boolean get() = androidSurface != null
|
||||
val isReadableOnDesktop: Boolean get() = desktopSurface != null
|
||||
|
||||
fun surfaceFor(platform: ReaderPlatform): ReaderFeatureSurface? {
|
||||
return when (platform) {
|
||||
ReaderPlatform.ANDROID -> androidSurface
|
||||
ReaderPlatform.DESKTOP -> desktopSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SharedFileCapabilities {
|
||||
val all: List<FileTypeCapability> = listOf(
|
||||
FileTypeCapability(
|
||||
type = FileType.EPUB,
|
||||
displayName = "EPUB",
|
||||
extensions = setOf("epub"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.EPUB_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.PDF,
|
||||
displayName = "PDF",
|
||||
extensions = setOf("pdf"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.TXT,
|
||||
displayName = "TXT",
|
||||
extensions = setOf("txt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.MD,
|
||||
displayName = "Markdown",
|
||||
extensions = setOf("md", "markdown"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.HTML,
|
||||
displayName = "HTML",
|
||||
extensions = setOf("html", "htm", "xhtml"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.MOBI,
|
||||
displayName = "MOBI",
|
||||
extensions = setOf("mobi", "azw", "azw3", "prc"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.FB2,
|
||||
displayName = "FB2",
|
||||
extensions = setOf("fb2"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBZ,
|
||||
displayName = "CBZ",
|
||||
extensions = setOf("cbz"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBR,
|
||||
displayName = "CBR",
|
||||
extensions = setOf("cbr"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CB7,
|
||||
displayName = "CB7",
|
||||
extensions = setOf("cb7"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.DOCX,
|
||||
displayName = "DOCX",
|
||||
extensions = setOf("docx"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.ODT,
|
||||
displayName = "ODT",
|
||||
extensions = setOf("odt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.FODT,
|
||||
displayName = "FODT",
|
||||
extensions = setOf("fodt"),
|
||||
androidSurface = ReaderFeatureSurface.EPUB_READER,
|
||||
desktopSurface = ReaderFeatureSurface.TEXT_READER
|
||||
)
|
||||
)
|
||||
|
||||
private val capabilitiesByType: Map<FileType, FileTypeCapability> = all.associateBy { it.type }
|
||||
private val typesByExtension: Map<String, FileType> = all
|
||||
.flatMap { capability -> capability.extensions.map { it.lowercase() to capability.type } }
|
||||
.toMap()
|
||||
|
||||
fun capabilityFor(type: FileType): FileTypeCapability? {
|
||||
return capabilitiesByType[type]
|
||||
}
|
||||
|
||||
fun displayNameFor(type: FileType): String {
|
||||
return capabilityFor(type)?.displayName ?: type.name
|
||||
}
|
||||
|
||||
fun fileTypeForName(fileName: String): FileType {
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.lowercase()
|
||||
return typesByExtension[extension] ?: FileType.UNKNOWN
|
||||
}
|
||||
|
||||
fun surfaceFor(type: FileType, platform: ReaderPlatform): ReaderFeatureSurface? {
|
||||
return capabilityFor(type)?.surfaceFor(platform)
|
||||
}
|
||||
|
||||
fun canOpen(type: FileType, platform: ReaderPlatform): Boolean {
|
||||
return surfaceFor(type, platform) != null
|
||||
}
|
||||
|
||||
fun readableTypesFor(platform: ReaderPlatform): Set<FileType> {
|
||||
return all.mapNotNullTo(mutableSetOf()) { capability ->
|
||||
capability.type.takeIf { capability.surfaceFor(platform) != null }
|
||||
}
|
||||
}
|
||||
|
||||
fun syncableTypesFor(platform: ReaderPlatform): Set<FileType> {
|
||||
return all.mapNotNullTo(mutableSetOf()) { capability ->
|
||||
capability.type.takeIf { capability.syncEligible && capability.surfaceFor(platform) != null }
|
||||
}
|
||||
}
|
||||
|
||||
fun supportedFormatsLabel(platform: ReaderPlatform): String {
|
||||
return all
|
||||
.filter { it.surfaceFor(platform) != null }
|
||||
.joinToString(", ") { it.displayName }
|
||||
}
|
||||
|
||||
fun desktopParityGaps(): List<FileType> {
|
||||
return all
|
||||
.filter { it.isReadableOnAndroid && !it.isReadableOnDesktop }
|
||||
.map { it.type }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -45,6 +48,8 @@ enum class ReadStatusFilter {
|
|||
COMPLETED
|
||||
}
|
||||
|
||||
const val IN_APP_STORAGE_SOURCE = "IN_APP_STORAGE"
|
||||
|
||||
enum class ShelfType {
|
||||
MANUAL,
|
||||
SMART,
|
||||
|
|
@ -72,15 +77,21 @@ data class BookItem(
|
|||
val type: FileType,
|
||||
val displayName: String,
|
||||
val timestamp: Long,
|
||||
val coverImagePath: String? = null,
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val progressPercentage: Float? = null,
|
||||
val isRecent: Boolean = true,
|
||||
val fileSize: Long = 0L,
|
||||
val sourceFolder: String? = null,
|
||||
val folderTextMetadataParsed: Boolean = false,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val tags: List<Tag> = emptyList()
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val lastPageIndex: Int? = null,
|
||||
val readerSettings: ReaderSettings? = null,
|
||||
val readerBookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val readerHighlights: List<UserHighlight> = emptyList()
|
||||
)
|
||||
|
||||
data class Shelf(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
data class SharedLibraryMutationResult(
|
||||
val state: SharedReaderScreenState,
|
||||
val shelfRecords: List<ShelfRecord>,
|
||||
val shelfRefs: List<BookShelfRef>
|
||||
)
|
||||
|
||||
object SharedLibraryEditor {
|
||||
fun cleanShelfName(name: String): String? {
|
||||
return name.trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun canMutateShelf(shelfId: String?): Boolean {
|
||||
val trimmed = shelfId?.trim()
|
||||
return !trimmed.isNullOrBlank() && trimmed != "unshelved"
|
||||
}
|
||||
|
||||
fun createShelfRecord(
|
||||
name: String,
|
||||
id: String,
|
||||
isSmart: Boolean = false,
|
||||
smartRulesJson: String? = null
|
||||
): ShelfRecord? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return ShelfRecord(
|
||||
id = trimmedId,
|
||||
name = trimmed,
|
||||
isSmart = isSmart,
|
||||
smartRulesJson = smartRulesJson
|
||||
)
|
||||
}
|
||||
|
||||
fun cleanTagName(name: String): String? {
|
||||
return name.trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun createTag(
|
||||
name: String,
|
||||
id: String,
|
||||
color: Int? = 0xFF64B5F6.toInt()
|
||||
): Tag? {
|
||||
val trimmed = cleanTagName(name) ?: return null
|
||||
val trimmedId = id.trim().takeIf { it.isNotBlank() } ?: return null
|
||||
return Tag(
|
||||
id = trimmedId,
|
||||
name = trimmed,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
|
||||
fun cleanBookIds(bookIds: Iterable<String>): Set<String> {
|
||||
return bookIds.mapTo(mutableSetOf()) { it.trim() }.filterTo(mutableSetOf()) { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun removeSelectedBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = state.selectedBookIds
|
||||
if (selected.isEmpty()) return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected },
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Removed ${selected.size} book(s) from the library.")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs.filterNot { it.bookId in selected }
|
||||
)
|
||||
}
|
||||
|
||||
fun createShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Created shelf \"$trimmed\".")),
|
||||
shelfRecords = shelfRecords + ShelfRecord(id = "shelf_$nowMillis", name = trimmed),
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun createSmartShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
definition: SmartCollectionDefinition,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val cleanedRules = definition.rules.mapNotNull { rule ->
|
||||
rule.value.trim().takeIf { it.isNotBlank() }?.let { value -> rule.copy(value = value) }
|
||||
}
|
||||
if (cleanedRules.isEmpty()) return null
|
||||
val cleanedDefinition = definition.copy(rules = cleanedRules)
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Created smart shelf \"$trimmed\".")),
|
||||
shelfRecords = shelfRecords + ShelfRecord(
|
||||
id = "smart_$nowMillis",
|
||||
name = trimmed,
|
||||
isSmart = true,
|
||||
smartRulesJson = SmartCollectionEngine.toJson(cleanedDefinition)
|
||||
),
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun renameShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelf: Shelf,
|
||||
name: String
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Renamed shelf to \"$trimmed\".")),
|
||||
shelfRecords = shelfRecords.map { if (it.id == shelf.id) it.copy(name = trimmed) else it },
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelf: Shelf
|
||||
): SharedLibraryMutationResult {
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Deleted shelf \"${shelf.name}\".")),
|
||||
shelfRecords = shelfRecords.filterNot { it.id == shelf.id },
|
||||
shelfRefs = shelfRefs.filterNot { it.shelfId == shelf.id }
|
||||
)
|
||||
}
|
||||
|
||||
fun removeFolder(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
folder: Shelf
|
||||
): SharedLibraryMutationResult? {
|
||||
if (folder.type != ShelfType.FOLDER) return null
|
||||
val folderBookIds = cleanBookIds(folder.books.map { it.id })
|
||||
if (folderBookIds.isEmpty()) return null
|
||||
val rootSourceFolder = folder.books.firstNotNullOfOrNull { it.sourceFolder }
|
||||
val remainingTabs = state.openTabIds.filterNot { it in folderBookIds }
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in folderBookIds },
|
||||
selectedBookIds = state.selectedBookIds - folderBookIds,
|
||||
pinnedHomeBookIds = state.pinnedHomeBookIds - folderBookIds,
|
||||
pinnedLibraryBookIds = state.pinnedLibraryBookIds - folderBookIds,
|
||||
openTabIds = remainingTabs,
|
||||
activeTabBookId = state.activeTabBookId?.takeUnless { it in folderBookIds },
|
||||
syncedFolders = if (folder.parentShelfId == null && rootSourceFolder != null) {
|
||||
state.syncedFolders.filterNot { it.uriString == rootSourceFolder }
|
||||
} else {
|
||||
state.syncedFolders
|
||||
},
|
||||
libraryFilters = if (rootSourceFolder != null) {
|
||||
state.libraryFilters.copy(sourceFolders = state.libraryFilters.sourceFolders - rootSourceFolder)
|
||||
} else {
|
||||
state.libraryFilters
|
||||
},
|
||||
bannerMessage = BannerMessage("Removed folder \"${folder.name}\" and ${folderBookIds.size} book(s) from the app.")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs.filterNot { it.bookId in folderBookIds }
|
||||
)
|
||||
}
|
||||
|
||||
fun markBookOpened(
|
||||
state: SharedReaderScreenState,
|
||||
bookId: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedReaderScreenState {
|
||||
val cleanedBookId = bookId.trim()
|
||||
if (cleanedBookId.isBlank()) return state
|
||||
return state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.map { book ->
|
||||
if (book.id == cleanedBookId) {
|
||||
book.copy(isRecent = true, timestamp = nowMillis)
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun addSelectedBooksToShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelfId: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = state.selectedBookIds
|
||||
if (selected.isEmpty()) return null
|
||||
val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
|
||||
val additions = selected.mapNotNull { bookId ->
|
||||
if (!existing.add(bookId to shelfId)) null else BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Added ${additions.size} book(s) to shelf.")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs + additions
|
||||
)
|
||||
}
|
||||
|
||||
fun tagSelectedBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
tagName: String,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val selected = cleanBookIds(state.selectedBookIds)
|
||||
val trimmed = cleanTagName(tagName) ?: return null
|
||||
if (selected.isEmpty()) return null
|
||||
val existingTag = state.allTags.firstOrNull { it.name.equals(trimmed, ignoreCase = true) }
|
||||
val tag = existingTag ?: Tag(
|
||||
id = trimmed.toStableTagId("tag_$nowMillis"),
|
||||
name = trimmed,
|
||||
color = 0xFF64B5F6.toInt()
|
||||
)
|
||||
val allTags = (state.allTags + tag).distinctBy { it.id }.sortedBy { it.name.lowercase() }
|
||||
val books = state.rawLibraryBooks.map { book ->
|
||||
if (book.id in selected && book.tags.none { it.id == tag.id }) {
|
||||
book.copy(tags = (book.tags + tag).sortedBy { it.name.lowercase() })
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = books,
|
||||
allTags = allTags,
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Tagged ${selected.size} book(s) with \"${tag.name}\".")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun updateBookMetadata(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
updated: BookItem,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult {
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.map { if (it.id == updated.id) updated.copy(timestamp = nowMillis) else it },
|
||||
allTags = (state.allTags + updated.tags).distinctBy { it.id }.sortedBy { it.name.lowercase() },
|
||||
bannerMessage = BannerMessage("Updated \"${updated.cardTitle()}\".")
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTagList(input: String, knownTags: List<Tag>, nowMillis: Long = currentTimestamp()): List<Tag> {
|
||||
return input.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctBy { it.lowercase() }
|
||||
.mapIndexed { index, name ->
|
||||
knownTags.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
?: Tag(
|
||||
id = name.toStableTagId("tag_${nowMillis + index}"),
|
||||
name = name,
|
||||
color = 0xFF64B5F6.toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toStableTagId(fallback: String): String {
|
||||
return lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').ifBlank { fallback }
|
||||
}
|
||||
|
|
@ -43,7 +43,8 @@ class LibraryProjector {
|
|||
timestamp = now + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.path?.parentPath()
|
||||
sourceFolder = file.sourceFolder ?: file.path?.parentPath(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +58,7 @@ class LibraryProjector {
|
|||
return when (sortOrder) {
|
||||
SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
|
||||
SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||
SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
|
||||
SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
|
||||
SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
|
||||
|
|
@ -79,7 +80,7 @@ class LibraryProjector {
|
|||
fun applyFilters(books: List<BookItem>, filters: LibraryFilters): List<BookItem> {
|
||||
return books.filter { book ->
|
||||
val matchesType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
|
||||
val matchesFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders
|
||||
val matchesFolder = book.matchesSourceFolders(filters.sourceFolders)
|
||||
val progress = book.progressPercentage ?: 0f
|
||||
val matchesStatus = when (filters.readStatus) {
|
||||
ReadStatusFilter.ALL -> true
|
||||
|
|
@ -148,26 +149,12 @@ private fun String.folderDisplayName(): String {
|
|||
data class ImportedFile(
|
||||
val name: String,
|
||||
val path: String?,
|
||||
val size: Long
|
||||
val size: Long,
|
||||
val sourceFolder: String? = null
|
||||
)
|
||||
|
||||
expect fun currentTimestamp(): Long
|
||||
|
||||
fun String.toFileType(): FileType {
|
||||
return when (substringAfterLast('.', "").lowercase()) {
|
||||
"pdf" -> FileType.PDF
|
||||
"epub" -> FileType.EPUB
|
||||
"mobi" -> FileType.MOBI
|
||||
"md" -> FileType.MD
|
||||
"txt" -> FileType.TXT
|
||||
"html", "htm" -> FileType.HTML
|
||||
"fb2" -> FileType.FB2
|
||||
"cbz" -> FileType.CBZ
|
||||
"cbr" -> FileType.CBR
|
||||
"cb7" -> FileType.CB7
|
||||
"docx" -> FileType.DOCX
|
||||
"odt" -> FileType.ODT
|
||||
"fodt" -> FileType.FODT
|
||||
else -> FileType.UNKNOWN
|
||||
}
|
||||
return SharedFileCapabilities.fileTypeForName(this)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,16 @@ class SharedLibraryStateProjector(
|
|||
val queried = filterBySearch(allLibraryBooks, current.searchQuery)
|
||||
val filtered = applyLibraryFilters(queried, current.libraryFilters)
|
||||
val sortedLibraryBooks = sortBooks(filtered, current.sortOrder)
|
||||
.withPinnedFirst(current.pinnedLibraryBookIds)
|
||||
val visibleRecentBooks = sortBooks(
|
||||
allLibraryBooks.filter { it.isRecent },
|
||||
current.sortOrder
|
||||
).take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
|
||||
)
|
||||
.withPinnedFirst(current.pinnedHomeBookIds)
|
||||
.take(if (current.recentFilesLimit > 0) current.recentFilesLimit else Int.MAX_VALUE)
|
||||
val openTabs = current.openTabIds.mapNotNull { tabId -> allLibraryBooks.find { it.id == tabId } }
|
||||
val openTabIds = openTabs.map { it.id }
|
||||
val activeTabBookId = current.activeTabBookId?.takeIf { it in openTabIds }
|
||||
val shelfProjection = buildShelves(
|
||||
allLibraryBooks = allLibraryBooks,
|
||||
shelfRecords = input.shelfRecords,
|
||||
|
|
@ -81,6 +86,8 @@ class SharedLibraryStateProjector(
|
|||
},
|
||||
shelves = shelfProjection.shelves,
|
||||
openTabs = openTabs,
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = activeTabBookId,
|
||||
booksAvailableForAdding = booksAvailableForAdding,
|
||||
allTags = input.tags
|
||||
)
|
||||
|
|
@ -99,13 +106,22 @@ class SharedLibraryStateProjector(
|
|||
val booksById = allLibraryBooks.associateBy { it.id }
|
||||
|
||||
shelfRecords.forEach { shelf ->
|
||||
val bookIds = shelfRefs
|
||||
.filter { it.shelfId == shelf.id }
|
||||
.sortedBy { it.addedAt }
|
||||
.map { it.bookId }
|
||||
val books = bookIds.mapNotNull { booksById[it] }
|
||||
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder)))
|
||||
shelvedBookIds.addAll(bookIds)
|
||||
if (shelf.isSmart && shelf.smartRulesJson != null) {
|
||||
val definition = SmartCollectionEngine.fromJson(shelf.smartRulesJson)
|
||||
if (definition != null) {
|
||||
val matchingBooks = allLibraryBooks.filter { SmartCollectionEngine.evaluate(it, definition) }
|
||||
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.SMART, sortBooks(matchingBooks, sortOrder)))
|
||||
shelvedBookIds.addAll(matchingBooks.map { it.id })
|
||||
}
|
||||
} else {
|
||||
val bookIds = shelfRefs
|
||||
.filter { it.shelfId == shelf.id }
|
||||
.sortedBy { it.addedAt }
|
||||
.map { it.bookId }
|
||||
val books = bookIds.mapNotNull { booksById[it] }
|
||||
shelves.add(Shelf(shelf.id, shelf.name, ShelfType.MANUAL, sortBooks(books, sortOrder)))
|
||||
shelvedBookIds.addAll(bookIds)
|
||||
}
|
||||
}
|
||||
|
||||
val tagShelves = tags.mapNotNull { tag ->
|
||||
|
|
@ -257,7 +273,7 @@ fun filterBySearch(books: List<BookItem>, searchQuery: String): List<BookItem> {
|
|||
fun applyLibraryFilters(books: List<BookItem>, filters: LibraryFilters): List<BookItem> {
|
||||
return books.filter { book ->
|
||||
val matchType = filters.fileTypes.isEmpty() || book.type in filters.fileTypes
|
||||
val matchFolder = filters.sourceFolders.isEmpty() || book.sourceFolder in filters.sourceFolders
|
||||
val matchFolder = book.matchesSourceFolders(filters.sourceFolders)
|
||||
val progress = book.progressPercentage ?: 0f
|
||||
val matchStatus = when (filters.readStatus) {
|
||||
ReadStatusFilter.ALL -> true
|
||||
|
|
@ -274,7 +290,7 @@ fun sortBooks(books: List<BookItem>, sortOrder: SortOrder): List<BookItem> {
|
|||
return when (sortOrder) {
|
||||
SortOrder.RECENT -> books.sortedByDescending { it.timestamp }
|
||||
SortOrder.TITLE_ASC -> books.sortedBy { it.title?.lowercase() ?: it.displayName.lowercase() }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedBy { it.author?.lowercase() ?: "" }
|
||||
SortOrder.AUTHOR_ASC -> books.sortedWith(compareBy(nullsLast()) { it.author?.lowercase() })
|
||||
SortOrder.PERCENT_ASC -> books.sortedBy { it.progressPercentage ?: 0f }
|
||||
SortOrder.PERCENT_DESC -> books.sortedByDescending { it.progressPercentage ?: 0f }
|
||||
SortOrder.SIZE_ASC -> books.sortedBy { it.fileSize }
|
||||
|
|
@ -301,7 +317,8 @@ fun SharedReaderScreenState.withImportedFiles(
|
|||
timestamp = now + index,
|
||||
title = file.name.substringBeforeLast('.'),
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.localPath?.parentPath()
|
||||
sourceFolder = file.sourceFolder ?: file.localPath?.parentPath(),
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -322,3 +339,13 @@ private fun String.parentPath(): String? {
|
|||
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()
|
||||
.sortedWith(
|
||||
compareByDescending<IndexedValue<BookItem>> { it.value.id in pinnedBookIds }
|
||||
.thenBy { it.index }
|
||||
)
|
||||
.map { it.value }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,507 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
|
||||
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
|
||||
|
||||
internal expect fun localFolderSyncSha256ShortHex(value: String): String
|
||||
|
||||
data class SharedFolderBookMetadata(
|
||||
val bookId: String,
|
||||
val title: String?,
|
||||
val author: String?,
|
||||
val displayName: String,
|
||||
val type: String,
|
||||
val lastChapterIndex: Int?,
|
||||
val lastPage: Int?,
|
||||
val lastPositionCfi: String?,
|
||||
val progressPercentage: Float,
|
||||
val isRecent: Boolean,
|
||||
val lastModifiedTimestamp: Long,
|
||||
val bookmarksJson: String?,
|
||||
val locatorBlockIndex: Int?,
|
||||
val locatorCharOffset: Int?,
|
||||
val customName: String?,
|
||||
val highlightsJson: String?
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return folderSyncJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
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),
|
||||
"lastPage" to JsonPrimitive(lastPage ?: -1),
|
||||
"lastPositionCfi" to lastPositionCfi.asJson(),
|
||||
"progressPercentage" to JsonPrimitive(progressPercentage.toDouble()),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"lastModifiedTimestamp" to JsonPrimitive(lastModifiedTimestamp),
|
||||
"bookmarksJson" to bookmarksJson.asJson(),
|
||||
"locatorBlockIndex" to JsonPrimitive(locatorBlockIndex ?: -1),
|
||||
"locatorCharOffset" to JsonPrimitive(locatorCharOffset ?: -1),
|
||||
"customName" to customName.asJson(),
|
||||
"highlightsJson" to highlightsJson.asJson()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun toBookItem(
|
||||
file: SharedFolderScannedFile,
|
||||
existing: BookItem? = null,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): BookItem {
|
||||
val parsedHighlights = highlightsJson
|
||||
?.let(EpubAnnotationSerializer::parseHighlightsJson)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val parsedBookmarks = parseReaderBookmarks(bookId)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
val parsedType = runCatching { FileType.valueOf(type) }.getOrNull() ?: file.type
|
||||
val metadataTimestamp = lastModifiedTimestamp.takeIf { it > 0L } ?: nowMillis
|
||||
|
||||
return (existing ?: BookItem(
|
||||
id = bookId,
|
||||
path = file.path,
|
||||
type = parsedType,
|
||||
displayName = displayName.ifBlank { file.name },
|
||||
timestamp = metadataTimestamp,
|
||||
title = title ?: displayName.ifBlank { file.name },
|
||||
author = author,
|
||||
fileSize = file.size,
|
||||
sourceFolder = file.sourceFolder,
|
||||
isRecent = isRecent
|
||||
)).copy(
|
||||
id = bookId,
|
||||
path = file.path,
|
||||
type = parsedType,
|
||||
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,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent || (existing?.isRecent ?: false),
|
||||
fileSize = file.size.takeIf { it > 0L } ?: existing?.fileSize ?: 0L,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = existing?.folderTextMetadataParsed ?: false,
|
||||
lastPageIndex = lastPage,
|
||||
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
|
||||
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReaderBookmarks(bookId: String): List<ReaderBookmark> {
|
||||
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
|
||||
.mapIndexed { index, bookmark ->
|
||||
val locator = bookmark.locator.withFallbacks(
|
||||
chapterIndex = bookmark.chapterIndex,
|
||||
cfi = bookmark.cfi,
|
||||
pageIndex = bookmark.pageInChapter?.minus(1),
|
||||
textQuote = bookmark.snippet
|
||||
)
|
||||
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
|
||||
ReaderBookmark(
|
||||
id = "bookmark_${localFolderSyncSha256ShortHex("$bookId:$index:${bookmark.cfi}")}",
|
||||
pageIndex = pageIndex.coerceAtLeast(0),
|
||||
chapterTitle = bookmark.chapterTitle,
|
||||
preview = bookmark.snippet,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJsonString(rawJson: String): SharedFolderBookMetadata? {
|
||||
val obj = runCatching { folderSyncJson.parseToJsonElement(rawJson).jsonObject }.getOrNull()
|
||||
?: return null
|
||||
val bookId = obj.string("bookId")?.takeIf { it.isNotBlank() } ?: return null
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = bookId,
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
displayName = obj.string("displayName") ?: "Unknown",
|
||||
type = obj.string("type") ?: FileType.PDF.name,
|
||||
lastChapterIndex = obj.sentinelInt("lastChapterIndex"),
|
||||
lastPage = obj.sentinelInt("lastPage"),
|
||||
lastPositionCfi = obj.string("lastPositionCfi"),
|
||||
progressPercentage = obj.double("progressPercentage")?.toFloat() ?: 0f,
|
||||
isRecent = obj.boolean("isRecent") ?: true,
|
||||
lastModifiedTimestamp = obj.long("lastModifiedTimestamp") ?: 0L,
|
||||
bookmarksJson = obj.string("bookmarksJson"),
|
||||
locatorBlockIndex = obj.sentinelInt("locatorBlockIndex"),
|
||||
locatorCharOffset = obj.sentinelInt("locatorCharOffset"),
|
||||
customName = obj.string("customName"),
|
||||
highlightsJson = obj.string("highlightsJson")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedFolderScannedFile(
|
||||
val name: String,
|
||||
val path: String,
|
||||
val sourceFolder: String,
|
||||
val relativePath: String,
|
||||
val type: FileType,
|
||||
val size: Long,
|
||||
val lastModified: Long
|
||||
) {
|
||||
val stableBookId: String
|
||||
get() = LocalFolderSyncEngine.buildStableBookId(name, relativePath)
|
||||
}
|
||||
|
||||
data class LocalFolderSyncStats(
|
||||
val scannedFiles: Int = 0,
|
||||
val supportedFiles: Int = 0,
|
||||
val newBooks: Int = 0,
|
||||
val updatedBooks: Int = 0,
|
||||
val unchangedBooks: Int = 0,
|
||||
val removedBooks: Int = 0,
|
||||
val migratedBooks: Int = 0,
|
||||
val remoteMetadataUpdates: Int = 0
|
||||
) {
|
||||
operator fun plus(other: LocalFolderSyncStats): LocalFolderSyncStats {
|
||||
return LocalFolderSyncStats(
|
||||
scannedFiles = scannedFiles + other.scannedFiles,
|
||||
supportedFiles = supportedFiles + other.supportedFiles,
|
||||
newBooks = newBooks + other.newBooks,
|
||||
updatedBooks = updatedBooks + other.updatedBooks,
|
||||
unchangedBooks = unchangedBooks + other.unchangedBooks,
|
||||
removedBooks = removedBooks + other.removedBooks,
|
||||
migratedBooks = migratedBooks + other.migratedBooks,
|
||||
remoteMetadataUpdates = remoteMetadataUpdates + other.remoteMetadataUpdates
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class LocalFolderSyncResult(
|
||||
val state: SharedReaderScreenState,
|
||||
val idMigrations: Map<String, String>,
|
||||
val removedBookIds: Set<String>,
|
||||
val stats: LocalFolderSyncStats
|
||||
)
|
||||
|
||||
object LocalFolderSyncEngine {
|
||||
fun buildStableBookId(name: String, relativePath: String): String {
|
||||
val normalizedRelativePath = relativePath.toSyncRelativePath().ifBlank { name }
|
||||
return if (normalizedRelativePath.equals(name, ignoreCase = true)) {
|
||||
"local_$name"
|
||||
} else {
|
||||
"local_${name}_${localFolderSyncSha256ShortHex(normalizedRelativePath.lowercase())}"
|
||||
}
|
||||
}
|
||||
|
||||
fun syncFolder(
|
||||
state: SharedReaderScreenState,
|
||||
folder: SyncedFolder,
|
||||
files: List<SharedFolderScannedFile>,
|
||||
remoteMetadata: Map<String, SharedFolderBookMetadata>,
|
||||
nowMillis: Long = currentTimestamp(),
|
||||
metadataOnly: Boolean = false
|
||||
): LocalFolderSyncResult {
|
||||
val folderRoot = folder.uriString
|
||||
val allowedTypes = folder.allowedFileTypes
|
||||
val booksById = linkedMapOf<String, BookItem>()
|
||||
state.rawLibraryBooks.forEach { booksById[it.id] = it }
|
||||
val idMigrations = linkedMapOf<String, String>()
|
||||
var stats = LocalFolderSyncStats(
|
||||
scannedFiles = files.size,
|
||||
supportedFiles = files.count { it.type in allowedTypes }
|
||||
)
|
||||
var removedIds = emptySet<String>()
|
||||
|
||||
val existingFolderBookIds = booksById.values
|
||||
.filter { it.sourceFolder == folderRoot }
|
||||
.mapTo(linkedSetOf()) { it.id }
|
||||
|
||||
remoteMetadata.forEach { (bookId, metadata) ->
|
||||
val existing = booksById[bookId]?.takeIf { it.sourceFolder == folderRoot }
|
||||
if (existing != null && metadata.lastModifiedTimestamp > existing.localFolderModifiedTimestamp()) {
|
||||
booksById[bookId] = existing.withAppliedFolderMetadata(metadata, nowMillis)
|
||||
stats = stats.copy(remoteMetadataUpdates = stats.remoteMetadataUpdates + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadataOnly) {
|
||||
val foundBookIds = linkedSetOf<String>()
|
||||
val folderBooksByPath = booksById.values
|
||||
.filter { it.sourceFolder == folderRoot && !it.path.isNullOrBlank() }
|
||||
.associateBy { it.path.orEmpty() }
|
||||
.toMutableMap()
|
||||
val legacyItemsByName = booksById.values
|
||||
.asSequence()
|
||||
.filter { it.sourceFolder == folderRoot }
|
||||
.filter { it.id.startsWith("local_${it.displayName}_") || it.id == it.path }
|
||||
.groupBy { it.displayName }
|
||||
.mapValues { (_, books) -> ArrayDeque<BookItem>().apply { addAll(books) } }
|
||||
.toMutableMap()
|
||||
|
||||
files
|
||||
.asSequence()
|
||||
.filter { it.type in allowedTypes }
|
||||
.sortedBy { it.relativePath.lowercase() }
|
||||
.forEach { file ->
|
||||
val stableId = file.stableBookId
|
||||
foundBookIds += stableId
|
||||
var existing = booksById[stableId]?.takeIf { it.sourceFolder == folderRoot }
|
||||
|
||||
if (existing == null) {
|
||||
val migrated = folderBooksByPath[file.path]?.takeIf { it.id != stableId }
|
||||
?: legacyItemsByName[file.name]?.firstOrNull { it.id != stableId }
|
||||
if (migrated != null) {
|
||||
val oldId = migrated.id
|
||||
val migratedBook = migrated.copy(id = stableId).withScannedFile(file)
|
||||
booksById.remove(oldId)
|
||||
booksById[stableId] = migratedBook
|
||||
idMigrations[oldId] = stableId
|
||||
legacyItemsByName[file.name]?.remove(migrated)
|
||||
existing = migratedBook
|
||||
stats = stats.copy(migratedBooks = stats.migratedBooks + 1)
|
||||
}
|
||||
}
|
||||
|
||||
val metadata = remoteMetadata[stableId]
|
||||
if (existing == null) {
|
||||
booksById[stableId] = metadata?.toBookItem(file, nowMillis = nowMillis)
|
||||
?: file.toBookItem(stableId, nowMillis)
|
||||
stats = stats.copy(newBooks = stats.newBooks + 1)
|
||||
} else {
|
||||
val updatedForFile = existing.withScannedFile(file)
|
||||
val updated = metadata
|
||||
?.takeIf { it.lastModifiedTimestamp > updatedForFile.localFolderModifiedTimestamp() }
|
||||
?.toBookItem(file = file, existing = updatedForFile, nowMillis = nowMillis)
|
||||
?: updatedForFile
|
||||
booksById[stableId] = updated
|
||||
if (updated != existing) {
|
||||
stats = stats.copy(updatedBooks = stats.updatedBooks + 1)
|
||||
} else {
|
||||
stats = stats.copy(unchangedBooks = stats.unchangedBooks + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removedIds = existingFolderBookIds
|
||||
.map { idMigrations[it] ?: it }
|
||||
.filter { it !in foundBookIds }
|
||||
.toSet()
|
||||
removedIds.forEach(booksById::remove)
|
||||
stats = stats.copy(removedBooks = removedIds.size)
|
||||
}
|
||||
|
||||
val syncedFolder = folder.copy(lastScanTime = nowMillis)
|
||||
val syncedFolders = (state.syncedFolders.filterNot { it.uriString == folderRoot } + syncedFolder)
|
||||
.sortedBy { it.name.lowercase() }
|
||||
val migratedState = state
|
||||
.withMigratedBookIds(idMigrations)
|
||||
val nextState = migratedState
|
||||
.withoutBookIds(removedIds)
|
||||
.copy(
|
||||
rawLibraryBooks = booksById.values.toList(),
|
||||
syncedFolders = syncedFolders,
|
||||
lastFolderScanTime = nowMillis
|
||||
)
|
||||
|
||||
return LocalFolderSyncResult(
|
||||
state = nextState,
|
||||
idMigrations = idMigrations,
|
||||
removedBookIds = removedIds,
|
||||
stats = stats
|
||||
)
|
||||
}
|
||||
|
||||
fun applyIdMigrationsToShelfRefs(
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
migrations: Map<String, String>
|
||||
): List<BookShelfRef> {
|
||||
if (migrations.isEmpty()) return shelfRefs
|
||||
return shelfRefs.map { ref ->
|
||||
migrations[ref.bookId]?.let { ref.copy(bookId = it) } ?: ref
|
||||
}.distinctBy { it.bookId to it.shelfId }
|
||||
}
|
||||
}
|
||||
|
||||
fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
||||
if (sourceFolder.isNullOrBlank()) return null
|
||||
|
||||
val bookmarksJson = readerBookmarks
|
||||
.mapNotNull { it.toEpubBookmarkOrNull() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::bookmarksToJson)
|
||||
val highlightsJson = readerHighlights
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let(EpubAnnotationSerializer::highlightsToJson)
|
||||
val hasProgress = (progressPercentage ?: 0f) > 0f || lastPageIndex != null
|
||||
val isDirty = isRecent || hasProgress || !bookmarksJson.isNullOrBlank() || !highlightsJson.isNullOrBlank()
|
||||
if (!isDirty) return null
|
||||
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = title,
|
||||
author = author,
|
||||
displayName = displayName,
|
||||
type = type.name,
|
||||
lastChapterIndex = null,
|
||||
lastPage = lastPageIndex,
|
||||
lastPositionCfi = null,
|
||||
progressPercentage = progressPercentage ?: 0f,
|
||||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = localFolderModifiedTimestamp(),
|
||||
bookmarksJson = bookmarksJson,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
customName = null,
|
||||
highlightsJson = highlightsJson
|
||||
)
|
||||
}
|
||||
|
||||
private val folderSyncJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private fun BookItem.withAppliedFolderMetadata(
|
||||
metadata: SharedFolderBookMetadata,
|
||||
nowMillis: Long
|
||||
): BookItem {
|
||||
val file = SharedFolderScannedFile(
|
||||
name = displayName,
|
||||
path = path.orEmpty(),
|
||||
sourceFolder = sourceFolder.orEmpty(),
|
||||
relativePath = displayName,
|
||||
type = runCatching { FileType.valueOf(metadata.type) }.getOrNull() ?: type,
|
||||
size = fileSize,
|
||||
lastModified = 0L
|
||||
)
|
||||
return metadata.toBookItem(file = file, existing = this, nowMillis = nowMillis)
|
||||
}
|
||||
|
||||
private fun SharedFolderScannedFile.toBookItem(bookId: String, nowMillis: Long): BookItem {
|
||||
return BookItem(
|
||||
id = bookId,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = name,
|
||||
timestamp = nowMillis,
|
||||
title = name.substringBeforeLast('.', missingDelimiterValue = name),
|
||||
fileSize = size,
|
||||
sourceFolder = sourceFolder,
|
||||
isRecent = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.withScannedFile(file: SharedFolderScannedFile): BookItem {
|
||||
val sizeChanged = fileSize > 0L && file.size > 0L && fileSize != file.size
|
||||
return copy(
|
||||
path = file.path,
|
||||
type = file.type,
|
||||
displayName = file.name,
|
||||
coverImagePath = if (sizeChanged) null else coverImagePath,
|
||||
fileSize = file.size.takeIf { it > 0L } ?: fileSize,
|
||||
sourceFolder = file.sourceFolder,
|
||||
folderTextMetadataParsed = if (sizeChanged) false else folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.localFolderModifiedTimestamp(): Long {
|
||||
return timestamp
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toEpubBookmarkOrNull(): EpubBookmark? {
|
||||
val chapterIndex = locator.chapterIndex ?: 0
|
||||
val cfi = locator.cfi ?: "desktop:$chapterIndex:$pageIndex"
|
||||
return EpubBookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = chapterTitle,
|
||||
label = null,
|
||||
snippet = preview,
|
||||
pageInChapter = pageIndex + 1,
|
||||
totalPagesInChapter = null,
|
||||
chapterIndex = chapterIndex,
|
||||
locator = locator.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = pageIndex,
|
||||
textQuote = preview
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.withMigratedBookIds(
|
||||
migrations: Map<String, String>
|
||||
): SharedReaderScreenState {
|
||||
if (migrations.isEmpty()) return this
|
||||
|
||||
fun String.migrated(): String = migrations[this] ?: this
|
||||
fun Set<String>.migrated(): Set<String> = mapTo(linkedSetOf()) { it.migrated() }
|
||||
|
||||
return copy(
|
||||
selectedBookIds = selectedBookIds.migrated(),
|
||||
booksSelectedForAdding = booksSelectedForAdding.migrated(),
|
||||
pinnedHomeBookIds = pinnedHomeBookIds.migrated(),
|
||||
pinnedLibraryBookIds = pinnedLibraryBookIds.migrated(),
|
||||
openTabIds = openTabIds.map { it.migrated() }.distinct(),
|
||||
activeTabBookId = activeTabBookId?.migrated(),
|
||||
selectedBookId = selectedBookId?.migrated()
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.withoutBookIds(bookIds: Set<String>): SharedReaderScreenState {
|
||||
if (bookIds.isEmpty()) return this
|
||||
return copy(
|
||||
selectedBookIds = selectedBookIds - bookIds,
|
||||
booksSelectedForAdding = booksSelectedForAdding - bookIds,
|
||||
pinnedHomeBookIds = pinnedHomeBookIds - bookIds,
|
||||
pinnedLibraryBookIds = pinnedLibraryBookIds - bookIds,
|
||||
openTabIds = openTabIds.filterNot { it in bookIds },
|
||||
activeTabBookId = activeTabBookId?.takeUnless { it in bookIds },
|
||||
selectedBookId = selectedBookId?.takeUnless { it in bookIds }
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.toSyncRelativePath(): String {
|
||||
return replace('\\', '/')
|
||||
.split('/')
|
||||
.filter { it.isNotBlank() && it != "." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.double(name: String): Double? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.sentinelInt(name: String): Int? {
|
||||
val value = runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
return value?.takeUnless { it == -1 }
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
|
|
@ -9,7 +9,13 @@ data class EpubBookmark(
|
|||
val snippet: String,
|
||||
val pageInChapter: Int?,
|
||||
val totalPagesInChapter: Int?,
|
||||
val chapterIndex: Int
|
||||
val chapterIndex: Int,
|
||||
val locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = pageInChapter?.minus(1),
|
||||
textQuote = snippet
|
||||
)
|
||||
)
|
||||
|
||||
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
|
||||
|
|
@ -29,13 +35,142 @@ enum class HighlightColor(val id: String, val color: Color, val cssClass: String
|
|||
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white")
|
||||
}
|
||||
|
||||
data class ReaderLocator(
|
||||
val chapterIndex: Int? = null,
|
||||
val chapterId: String? = null,
|
||||
val href: String? = null,
|
||||
val pageIndex: Int? = null,
|
||||
val startOffset: Int? = null,
|
||||
val endOffset: Int? = null,
|
||||
val textQuote: String? = null,
|
||||
val cfi: String? = null
|
||||
) {
|
||||
val hasTextRange: Boolean
|
||||
get() = startOffset != null && endOffset != null && endOffset >= startOffset
|
||||
|
||||
fun withFallbacks(
|
||||
chapterIndex: Int? = null,
|
||||
chapterId: String? = null,
|
||||
href: String? = null,
|
||||
pageIndex: Int? = null,
|
||||
startOffset: Int? = null,
|
||||
endOffset: Int? = null,
|
||||
textQuote: String? = null,
|
||||
cfi: String? = null
|
||||
): ReaderLocator {
|
||||
return copy(
|
||||
chapterIndex = this.chapterIndex ?: chapterIndex,
|
||||
chapterId = this.chapterId ?: chapterId,
|
||||
href = this.href ?: href,
|
||||
pageIndex = this.pageIndex ?: pageIndex,
|
||||
startOffset = this.startOffset ?: startOffset,
|
||||
endOffset = this.endOffset ?: endOffset,
|
||||
textQuote = this.textQuote ?: textQuote,
|
||||
cfi = this.cfi ?: cfi
|
||||
)
|
||||
}
|
||||
|
||||
fun sameLocation(other: ReaderLocator): Boolean {
|
||||
val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex
|
||||
if (!sameChapter) return false
|
||||
|
||||
if (hasTextRange && other.hasTextRange) {
|
||||
return startOffset == other.startOffset && endOffset == other.endOffset
|
||||
}
|
||||
|
||||
if (pageIndex != null && other.pageIndex != null) {
|
||||
return pageIndex == other.pageIndex
|
||||
}
|
||||
|
||||
return cfi != null && cfi == other.cfi
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromLegacy(
|
||||
chapterIndex: Int? = null,
|
||||
cfi: String? = null,
|
||||
pageIndex: Int? = null,
|
||||
textQuote: String? = null
|
||||
): ReaderLocator {
|
||||
val desktopParts = cfi
|
||||
?.takeIf { it.startsWith("desktop:") }
|
||||
?.split(':')
|
||||
.orEmpty()
|
||||
val parsedChapterIndex = desktopParts.getOrNull(1)?.toIntOrNull()
|
||||
val possibleStartOffset = desktopParts.getOrNull(2)?.toIntOrNull()
|
||||
val possibleEndOffset = desktopParts.getOrNull(3)?.toIntOrNull()
|
||||
val hasOffsetRange = desktopParts.size == 4 &&
|
||||
possibleStartOffset != null &&
|
||||
possibleEndOffset != null &&
|
||||
possibleStartOffset >= 0 &&
|
||||
possibleEndOffset >= possibleStartOffset &&
|
||||
possibleEndOffset - possibleStartOffset <= 100_000
|
||||
val parsedStartOffset = if (hasOffsetRange) possibleStartOffset else null
|
||||
val parsedEndOffset = if (hasOffsetRange) possibleEndOffset else null
|
||||
val parsedPageIndex = when {
|
||||
pageIndex != null -> pageIndex
|
||||
desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) ->
|
||||
desktopParts.getOrNull(2)?.toIntOrNull()
|
||||
else -> null
|
||||
}
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex ?: parsedChapterIndex,
|
||||
pageIndex = parsedPageIndex,
|
||||
startOffset = parsedStartOffset,
|
||||
endOffset = parsedEndOffset,
|
||||
textQuote = textQuote,
|
||||
cfi = cfi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderHighlightPalette(
|
||||
val colors: List<HighlightColor> = defaultColors
|
||||
) {
|
||||
fun sanitized(): ReaderHighlightPalette {
|
||||
val distinct = colors.distinct().filter { it in HighlightColor.entries }
|
||||
return copy(colors = distinct.ifEmpty { defaultColors })
|
||||
}
|
||||
|
||||
fun contains(color: HighlightColor): Boolean {
|
||||
return color in sanitized().colors
|
||||
}
|
||||
|
||||
fun withColor(color: HighlightColor, enabled: Boolean): ReaderHighlightPalette {
|
||||
val next = if (enabled) {
|
||||
colors + color
|
||||
} else {
|
||||
colors - color
|
||||
}
|
||||
return copy(colors = next).sanitized()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val defaultColors: List<HighlightColor>
|
||||
get() = listOf(
|
||||
HighlightColor.YELLOW,
|
||||
HighlightColor.GREEN,
|
||||
HighlightColor.BLUE,
|
||||
HighlightColor.RED,
|
||||
HighlightColor.PURPLE,
|
||||
HighlightColor.ORANGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UserHighlight(
|
||||
val id: String,
|
||||
val cfi: String,
|
||||
val text: String,
|
||||
val color: HighlightColor,
|
||||
val chapterIndex: Int,
|
||||
val note: String? = null
|
||||
val note: String? = null,
|
||||
val locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
|
||||
fun escapeJsString(value: String): String {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
object EpubAnnotationSerializer {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun parseBookmarksJson(rawJson: String?, chapterTitles: List<String> = emptyList()): Set<EpubBookmark> {
|
||||
if (rawJson.isNullOrBlank()) return emptySet()
|
||||
val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptySet()
|
||||
return root.mapNotNull { element ->
|
||||
when (element) {
|
||||
is JsonObject -> element.asBookmarkOrNull(chapterTitles)
|
||||
else -> element.contentOrNull()
|
||||
?.let { rawBookmark -> parseBookmarkObject(rawBookmark, chapterTitles) }
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun parseBookmarkEntries(entries: Collection<String>, chapterTitles: List<String> = emptyList()): Set<EpubBookmark> {
|
||||
return entries.mapNotNull { parseBookmarkObject(it, chapterTitles) }.toSet()
|
||||
}
|
||||
|
||||
fun bookmarksToJson(bookmarks: Collection<EpubBookmark>): String {
|
||||
val bookmarkEntries = bookmarks.map { JsonPrimitive(it.toJsonString()) }
|
||||
return json.encodeToString(JsonElement.serializer(), JsonArray(bookmarkEntries))
|
||||
}
|
||||
|
||||
fun parseHighlightsJson(rawJson: String?): List<UserHighlight> {
|
||||
if (rawJson.isNullOrBlank()) return emptyList()
|
||||
val root = runCatching { json.parseToJsonElement(rawJson).jsonArray }.getOrNull() ?: return emptyList()
|
||||
return root.mapNotNull { element ->
|
||||
runCatching { element.jsonObject.asHighlightOrNull() }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun parseHighlightJson(rawJson: String?): UserHighlight? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
return runCatching { json.parseToJsonElement(rawJson).jsonObject.asHighlightOrNull() }.getOrNull()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun highlightsToJson(highlights: Collection<UserHighlight>): String {
|
||||
return json.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonArray(highlights.map { it.toJsonObject() })
|
||||
)
|
||||
}
|
||||
|
||||
fun processAndAddHighlight(
|
||||
newCfi: String,
|
||||
newText: String,
|
||||
newColor: HighlightColor,
|
||||
chapterIndex: Int,
|
||||
currentList: MutableList<UserHighlight>,
|
||||
locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
): String {
|
||||
val normalizedLocator = locator.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
val exactMatchIndex = currentList.indexOfFirst {
|
||||
it.chapterIndex == chapterIndex &&
|
||||
(it.cfi == newCfi || it.locator.sameLocation(normalizedLocator))
|
||||
}
|
||||
|
||||
if (exactMatchIndex != -1) {
|
||||
val existing = currentList[exactMatchIndex]
|
||||
currentList[exactMatchIndex] = existing.copy(
|
||||
cfi = newCfi,
|
||||
color = newColor,
|
||||
text = newText,
|
||||
locator = existing.locator.copy(cfi = newCfi, textQuote = newText).withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = newCfi,
|
||||
textQuote = newText
|
||||
)
|
||||
)
|
||||
return newCfi
|
||||
}
|
||||
|
||||
currentList.add(
|
||||
UserHighlight(
|
||||
id = stableHighlightId(newCfi, chapterIndex),
|
||||
cfi = newCfi,
|
||||
text = newText,
|
||||
color = newColor,
|
||||
chapterIndex = chapterIndex,
|
||||
note = null,
|
||||
locator = normalizedLocator
|
||||
)
|
||||
)
|
||||
return newCfi
|
||||
}
|
||||
|
||||
private fun parseBookmarkObject(rawJson: String, chapterTitles: List<String>): EpubBookmark? {
|
||||
return runCatching { json.parseToJsonElement(rawJson).jsonObject.asBookmarkOrNull(chapterTitles) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun EpubBookmark.toJsonString(): String {
|
||||
return json.encodeToString(JsonElement.serializer(), toJsonObject())
|
||||
}
|
||||
|
||||
private fun EpubBookmark.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
put("cfi", JsonPrimitive(cfi))
|
||||
put("chapterTitle", JsonPrimitive(chapterTitle))
|
||||
put("label", label.asJson())
|
||||
put("snippet", JsonPrimitive(snippet))
|
||||
pageInChapter?.let { put("pageInChapter", JsonPrimitive(it)) }
|
||||
totalPagesInChapter?.let { put("totalPagesInChapter", JsonPrimitive(it)) }
|
||||
put("chapterIndex", JsonPrimitive(chapterIndex))
|
||||
put("locator", locator.toJsonObject())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.asBookmarkOrNull(chapterTitles: List<String>): EpubBookmark? {
|
||||
val cfi = string("cfi") ?: return null
|
||||
val chapterTitle = string("chapterTitle") ?: return null
|
||||
val chapterIndex = int("chapterIndex")
|
||||
?: chapterTitles.indexOfFirst { it == chapterTitle }.coerceAtLeast(0)
|
||||
return EpubBookmark(
|
||||
cfi = cfi,
|
||||
chapterTitle = chapterTitle,
|
||||
label = string("label"),
|
||||
snippet = string("snippet") ?: "",
|
||||
pageInChapter = int("pageInChapter"),
|
||||
totalPagesInChapter = int("totalPagesInChapter"),
|
||||
chapterIndex = chapterIndex,
|
||||
locator = this["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = int("pageInChapter")?.minus(1),
|
||||
textQuote = string("snippet") ?: ""
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
pageIndex = int("pageInChapter")?.minus(1),
|
||||
textQuote = string("snippet") ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.asHighlightOrNull(): UserHighlight? {
|
||||
val cfi = string("cfi") ?: return null
|
||||
val text = string("text") ?: return null
|
||||
val chapterIndex = int("chapterIndex") ?: return null
|
||||
val colorId = string("colorId")
|
||||
val color = HighlightColor.entries.firstOrNull { it.id == colorId } ?: HighlightColor.YELLOW
|
||||
val note = string("note")?.takeIf { it.isNotBlank() }
|
||||
return UserHighlight(
|
||||
id = string("id")?.takeIf { it.isNotBlank() } ?: stableHighlightId(cfi, chapterIndex),
|
||||
cfi = cfi,
|
||||
text = text,
|
||||
color = color,
|
||||
chapterIndex = chapterIndex,
|
||||
note = note,
|
||||
locator = this["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserHighlight.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"cfi" to JsonPrimitive(cfi),
|
||||
"text" to JsonPrimitive(text),
|
||||
"colorId" to JsonPrimitive(color.id),
|
||||
"chapterIndex" to JsonPrimitive(chapterIndex),
|
||||
"note" to (note ?: "").asJson(),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) }
|
||||
chapterId?.let { put("chapterId", JsonPrimitive(it)) }
|
||||
href?.let { put("href", JsonPrimitive(it)) }
|
||||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderLocator(
|
||||
chapterIndex = obj.int("chapterIndex"),
|
||||
chapterId = obj.string("chapterId"),
|
||||
href = obj.string("href"),
|
||||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
}
|
||||
|
||||
private fun stableHighlightId(cfi: String, chapterIndex: Int): String {
|
||||
val key = "$chapterIndex:$cfi"
|
||||
var hash = 1125899906842597L
|
||||
key.forEach { char -> hash = 31 * hash + char.code }
|
||||
return "highlight_${hash.toString(16)}"
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.contentOrNull(): String? {
|
||||
return runCatching { takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
|
||||
ORIGINAL("original", "Original", "Original"),
|
||||
|
|
@ -29,6 +36,11 @@ enum class PageInfoMode(val id: Int, val title: String) {
|
|||
HIDDEN(2, "Always Hide")
|
||||
}
|
||||
|
||||
enum class PageInfoPosition(val id: Int, val title: String) {
|
||||
BOTTOM(0, "Bottom"),
|
||||
TOP(1, "Top")
|
||||
}
|
||||
|
||||
data class FormatSettings(
|
||||
val fontSize: Float,
|
||||
val lineHeight: Float,
|
||||
|
|
@ -37,16 +49,26 @@ data class FormatSettings(
|
|||
val horizontalMargin: Float,
|
||||
val font: ReaderFont,
|
||||
val customPath: String?,
|
||||
val textAlign: ReaderTextAlign
|
||||
val textAlign: ReaderTextAlign,
|
||||
val verticalMargin: Float = 1.0f
|
||||
)
|
||||
|
||||
enum class ReaderTexture(val id: String, val displayName: String) {
|
||||
PAPER("paper", "Paper"),
|
||||
CANVAS("canvas", "Canvas"),
|
||||
EINK("eink", "E-Ink"),
|
||||
SLATE("slate", "Slate")
|
||||
enum class ReaderTexture(val id: String, val displayName: String, val assetPath: String) {
|
||||
NATURAL_WHITE("asset:ep_naturalwhite.webp", "Natural White", "textures/ep_naturalwhite.webp"),
|
||||
NATURAL_BLACK("asset:ep_naturalblack.webp", "Natural Black", "textures/ep_naturalblack.webp"),
|
||||
LIGHT_VENEER("asset:light-veneer.webp", "Light Veneer", "textures/light-veneer.webp"),
|
||||
RETINA_WOOD("asset:retina_wood.webp", "Retina Wood", "textures/retina_wood.webp"),
|
||||
GREY_WASH("asset:grey_wash_wall.webp", "Grey Wash", "textures/grey_wash_wall.webp"),
|
||||
CLASSY_FABRIC("asset:classy_fabric.webp", "Classy Fabric", "textures/classy_fabric.webp"),
|
||||
RETRO_INTRO("asset:retro_intro.webp", "Retro Intro", "textures/retro_intro.webp"),
|
||||
PAPER("paper", "Paper", "textures/texture_paper.png"),
|
||||
CANVAS("canvas", "Canvas", "textures/texture_canvas.png"),
|
||||
EINK("eink", "E-Ink", "textures/texture_eink.webp"),
|
||||
SLATE("slate", "Slate", "textures/texture_slate.png")
|
||||
}
|
||||
|
||||
const val ReaderTextureFilePrefix = "file:"
|
||||
|
||||
data class ReaderTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
|
@ -63,5 +85,124 @@ val BuiltInReaderThemes = listOf(
|
|||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
|
||||
val BuiltInPdfReaderThemes = listOf(
|
||||
ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true),
|
||||
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
|
||||
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
|
||||
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
|
||||
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
|
||||
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true),
|
||||
ReaderTheme("pdf_natural_white_texture", "Natural White", Color(0xFFF7F1E5), Color(0xFF1D1B18), false, textureId = ReaderTexture.NATURAL_WHITE.id),
|
||||
ReaderTheme("pdf_retina_texture", "Retina", Color(0xFFF1E4CD), Color(0xFF2A2119), false, textureId = ReaderTexture.RETINA_WOOD.id),
|
||||
ReaderTheme("pdf_veneer_texture", "Veneer", Color(0xFFF4E7CF), Color(0xFF2A2119), false, textureId = ReaderTexture.LIGHT_VENEER.id),
|
||||
ReaderTheme("pdf_grey_wash_texture", "Grey Wash", Color(0xFF202124), Color(0xFFFFFFFF), true, textureId = ReaderTexture.GREY_WASH.id),
|
||||
ReaderTheme("pdf_fabric_texture", "Fabric", Color(0xFF262626), Color(0xFFE8E2D8), true, textureId = ReaderTexture.CLASSY_FABRIC.id),
|
||||
ReaderTheme("pdf_retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
|
||||
fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
|
||||
val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx)
|
||||
val verticalMarginPx = (ReaderAppearanceDefaults.marginPx * verticalMargin).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minMarginPx, ReaderAppearanceDefaults.maxMarginPx)
|
||||
return base.copy(
|
||||
fontSize = (ReaderAppearanceDefaults.fontSizePx * fontSize).roundToInt()
|
||||
.coerceIn(ReaderAppearanceDefaults.minFontSizePx, ReaderAppearanceDefaults.maxFontSizePx),
|
||||
lineSpacing = (ReaderAppearanceDefaults.lineSpacing * lineHeight)
|
||||
.coerceIn(ReaderAppearanceDefaults.minLineSpacing, ReaderAppearanceDefaults.maxLineSpacing),
|
||||
margin = max(horizontalMarginPx, verticalMarginPx),
|
||||
horizontalMargin = horizontalMarginPx,
|
||||
verticalMargin = verticalMarginPx,
|
||||
textAlign = textAlign.toSharedReaderTextAlign(),
|
||||
fontFamily = customPath?.takeIf { it.isNotBlank() } ?: font.toReaderSettingsFontFamily(),
|
||||
customFontPath = customPath?.takeIf { it.isNotBlank() },
|
||||
paragraphSpacing = paragraphGap.coerceIn(
|
||||
ReaderAppearanceDefaults.minParagraphSpacing,
|
||||
ReaderAppearanceDefaults.maxParagraphSpacing
|
||||
),
|
||||
imageScale = imageSize.coerceIn(
|
||||
ReaderAppearanceDefaults.minImageScale,
|
||||
ReaderAppearanceDefaults.maxImageScale
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
|
||||
return base.copy(
|
||||
darkMode = isDark,
|
||||
themeId = id,
|
||||
textureId = textureId,
|
||||
backgroundColorArgb = backgroundColor.takeIf { it.isSpecified }?.toArgb()?.toLong(),
|
||||
textColorArgb = textColor.takeIf { it.isSpecified }?.toArgb()?.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
fun readerThemeById(themeId: String?): ReaderTheme? {
|
||||
return BuiltInReaderThemes.firstOrNull { it.id == themeId }
|
||||
}
|
||||
|
||||
fun readerTextureDisplayName(textureId: String?): String {
|
||||
return if (textureId == null) {
|
||||
"None"
|
||||
} else {
|
||||
ReaderTexture.entries.firstOrNull { it.id == textureId }?.displayName
|
||||
?: textureId
|
||||
.removePrefix(ReaderTextureFilePrefix)
|
||||
.substringAfterLast('/')
|
||||
.substringAfterLast('\\')
|
||||
.let { fileName -> fileName.substringBeforeLast('.', missingDelimiterValue = fileName) }
|
||||
.ifBlank { "Custom Image" }
|
||||
}
|
||||
}
|
||||
|
||||
fun RenderMode.toReaderReadingMode(): ReaderReadingMode {
|
||||
return when (this) {
|
||||
RenderMode.VERTICAL_SCROLL -> ReaderReadingMode.VERTICAL
|
||||
RenderMode.PAGINATED -> ReaderReadingMode.PAGINATED
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
|
||||
return when (this) {
|
||||
ReaderTextAlign.DEFAULT,
|
||||
ReaderTextAlign.LEFT -> SharedReaderTextAlign.START
|
||||
ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderFont.toReaderSettingsFontFamily(): String {
|
||||
return when (this) {
|
||||
ReaderFont.ORIGINAL -> "Default"
|
||||
ReaderFont.MERRIWEATHER,
|
||||
ReaderFont.LORA -> "Serif"
|
||||
ReaderFont.LATO,
|
||||
ReaderFont.LEXEND -> "Sans"
|
||||
ReaderFont.ROBOTO_MONO -> "Mono"
|
||||
}
|
||||
}
|
||||
|
||||
private object ReaderAppearanceDefaults {
|
||||
const val fontSizePx = 18f
|
||||
const val minFontSizePx = 12
|
||||
const val maxFontSizePx = 42
|
||||
const val lineSpacing = 1.45f
|
||||
const val minLineSpacing = 1.0f
|
||||
const val maxLineSpacing = 2.8f
|
||||
const val marginPx = 48f
|
||||
const val minMarginPx = 0
|
||||
const val maxMarginPx = 160
|
||||
const val minParagraphSpacing = 0.5f
|
||||
const val maxParagraphSpacing = 2.5f
|
||||
const val minImageScale = 0.5f
|
||||
const val maxImageScale = 2.0f
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,734 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticFlexContainer
|
||||
import com.aryan.reader.paginatedreader.SemanticList
|
||||
import com.aryan.reader.paginatedreader.SemanticTable
|
||||
import com.aryan.reader.paginatedreader.SemanticTextBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
import com.aryan.reader.shared.reader.SharedEpubChapter
|
||||
|
||||
const val GEMINI_CLOUD_TTS_MODEL = "gemini-3.1-flash-live-preview"
|
||||
const val GEMINI_CLOUD_TTS_MODEL_ID = "gemini:$GEMINI_CLOUD_TTS_MODEL"
|
||||
const val DEFAULT_CLOUD_TTS_SPEAKER_ID = "Aoede"
|
||||
const val READER_TTS_CHUNK_MAX_LENGTH = 250
|
||||
|
||||
data class ReaderCloudTtsVoice(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
enum class ReaderAiFeature(val displayName: String) {
|
||||
DEFINE("Smart dictionary"),
|
||||
SUMMARIZE("Summaries"),
|
||||
RECAP("Recaps")
|
||||
}
|
||||
|
||||
data class ReaderAiModelOption(
|
||||
val provider: String,
|
||||
val name: String,
|
||||
val label: String = "${provider.replaceFirstChar { it.uppercaseChar() }} - $name"
|
||||
) {
|
||||
val id: String = "$provider:$name"
|
||||
}
|
||||
|
||||
data class ReaderAiByokSettings(
|
||||
val geminiKey: String = "",
|
||||
val groqKey: String = "",
|
||||
val useOneModel: Boolean = true,
|
||||
val modelForAll: String = "",
|
||||
val defineModel: String = "",
|
||||
val summarizeModel: String = "",
|
||||
val recapModel: String = "",
|
||||
val ttsModel: String = "",
|
||||
val hideReaderAiFeatures: Boolean = false,
|
||||
val ttsSpeakerId: String = DEFAULT_CLOUD_TTS_SPEAKER_ID
|
||||
) {
|
||||
fun sanitized(): ReaderAiByokSettings {
|
||||
val knownTextModelIds = ReaderAiModelOptions.mapTo(mutableSetOf()) { it.id }
|
||||
return copy(
|
||||
geminiKey = geminiKey.trim(),
|
||||
groqKey = groqKey.trim(),
|
||||
modelForAll = modelForAll.takeIf { it in knownTextModelIds }.orEmpty(),
|
||||
defineModel = defineModel.takeIf { it in knownTextModelIds }.orEmpty(),
|
||||
summarizeModel = summarizeModel.takeIf { it in knownTextModelIds }.orEmpty(),
|
||||
recapModel = recapModel.takeIf { it in knownTextModelIds }.orEmpty(),
|
||||
ttsModel = ttsModel.takeIf { it == GEMINI_CLOUD_TTS_MODEL_ID }.orEmpty(),
|
||||
ttsSpeakerId = ttsSpeakerId.ifBlank { DEFAULT_CLOUD_TTS_SPEAKER_ID }
|
||||
)
|
||||
}
|
||||
|
||||
fun modelIdFor(feature: ReaderAiFeature): String {
|
||||
return if (useOneModel) {
|
||||
modelForAll
|
||||
} else {
|
||||
when (feature) {
|
||||
ReaderAiFeature.DEFINE -> defineModel
|
||||
ReaderAiFeature.SUMMARIZE -> summarizeModel
|
||||
ReaderAiFeature.RECAP -> recapModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun apiKeyFor(provider: String): String {
|
||||
return when (provider) {
|
||||
"gemini" -> geminiKey
|
||||
"groq" -> groqKey
|
||||
else -> ""
|
||||
}.trim()
|
||||
}
|
||||
|
||||
val hasAnyAiKey: Boolean get() = geminiKey.isNotBlank() || groqKey.isNotBlank()
|
||||
val areReaderAiFeaturesAvailable: Boolean get() = !hideReaderAiFeatures && hasAnyAiKey
|
||||
val isCloudTtsAvailable: Boolean get() = geminiKey.isNotBlank() && ttsModel == GEMINI_CLOUD_TTS_MODEL_ID
|
||||
}
|
||||
|
||||
val ReaderAiModelOptions = listOf(
|
||||
ReaderAiModelOption("groq", "qwen/qwen3-32b"),
|
||||
ReaderAiModelOption("groq", "llama-3.3-70b-versatile"),
|
||||
ReaderAiModelOption("groq", "llama-3.1-8b-instant"),
|
||||
ReaderAiModelOption("gemini", "gemma-4-26b-a4b-it"),
|
||||
ReaderAiModelOption("gemini", "gemma-4-31b-it"),
|
||||
ReaderAiModelOption("gemini", "gemini-flash-lite-latest"),
|
||||
ReaderAiModelOption("gemini", "gemini-2.5-flash-lite"),
|
||||
ReaderAiModelOption("gemini", "gemini-3.1-flash-lite-preview")
|
||||
)
|
||||
|
||||
val ReaderCloudTtsVoices = listOf(
|
||||
ReaderCloudTtsVoice("Zephyr", "Zephyr", "Bright, Higher pitch"),
|
||||
ReaderCloudTtsVoice("Puck", "Puck", "Upbeat, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Charon", "Charon", "Informative, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Kore", "Kore", "Firm, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Fenrir", "Fenrir", "Excitable, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Leda", "Leda", "Youthful, Higher pitch"),
|
||||
ReaderCloudTtsVoice("Orus", "Orus", "Firm, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Aoede", "Aoede", "Breezy, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Callirrhoe", "Callirrhoe", "Easy-going, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Autonoe", "Autonoe", "Bright, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Enceladus", "Enceladus", "Breathy, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Iapetus", "Iapetus", "Clear, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Umbriel", "Umbriel", "Easy-going, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Algieba", "Algieba", "Smooth, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Despina", "Despina", "Smooth, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Erinome", "Erinome", "Clear, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Algenib", "Algenib", "Gravelly, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Rasalgethi", "Rasalgethi", "Informative, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Laomedeia", "Laomedeia", "Upbeat, Higher pitch"),
|
||||
ReaderCloudTtsVoice("Achernar", "Achernar", "Soft, Higher pitch"),
|
||||
ReaderCloudTtsVoice("Alnilam", "Alnilam", "Firm, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Schedar", "Schedar", "Even, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Gacrux", "Gacrux", "Mature, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Pulcherrima", "Pulcherrima", "Forward, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Achird", "Achird", "Friendly, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Zubenelgenubi", "Zubenelgenubi", "Casual, Lower middle pitch"),
|
||||
ReaderCloudTtsVoice("Vindemiatrix", "Vindemiatrix", "Gentle, Middle pitch"),
|
||||
ReaderCloudTtsVoice("Sadachbia", "Sadachbia", "Lively, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Sadaltager", "Sadaltager", "Lively, Lower pitch"),
|
||||
ReaderCloudTtsVoice("Sulafat", "Sulafat", "Warm, Middle pitch")
|
||||
)
|
||||
|
||||
val ReaderCloudTtsSpeakers = ReaderCloudTtsVoices.map { it.id }
|
||||
|
||||
fun readerCloudTtsVoiceById(id: String): ReaderCloudTtsVoice? {
|
||||
return ReaderCloudTtsVoices.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
fun formatReaderTtsBytes(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
val units = listOf("KB", "MB", "GB", "TB", "PB")
|
||||
var value = bytes.toDouble() / 1024.0
|
||||
var unitIndex = 0
|
||||
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
||||
value /= 1024.0
|
||||
unitIndex++
|
||||
}
|
||||
return "${(value * 10).toInt() / 10.0} ${units[unitIndex]}"
|
||||
}
|
||||
|
||||
fun splitReaderTextIntoTtsChunks(
|
||||
text: String,
|
||||
maxLength: Int = READER_TTS_CHUNK_MAX_LENGTH
|
||||
): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
|
||||
val sentences = text.trim()
|
||||
.split(sentenceBoundaryRegex)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
if (sentences.isEmpty()) return emptyList()
|
||||
|
||||
val chunks = mutableListOf<String>()
|
||||
val currentChunk = StringBuilder()
|
||||
fun flush() {
|
||||
if (currentChunk.isNotEmpty()) {
|
||||
chunks += currentChunk.toString()
|
||||
currentChunk.clear()
|
||||
}
|
||||
}
|
||||
|
||||
sentences.forEach { sentence ->
|
||||
if (sentence.length > maxLength) {
|
||||
flush()
|
||||
chunks += sentence
|
||||
return@forEach
|
||||
}
|
||||
if (currentChunk.isNotEmpty() && currentChunk.length + sentence.length + 1 > maxLength) {
|
||||
flush()
|
||||
}
|
||||
if (currentChunk.isNotEmpty()) currentChunk.append(' ')
|
||||
currentChunk.append(sentence)
|
||||
}
|
||||
flush()
|
||||
return chunks
|
||||
}
|
||||
|
||||
fun readerAiModelById(id: String): ReaderAiModelOption? {
|
||||
return ReaderAiModelOptions.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
fun maskedReaderAiKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
return when {
|
||||
trimmed.isBlank() -> ""
|
||||
trimmed.length <= 6 -> "***"
|
||||
else -> "${trimmed.take(3)}...${trimmed.takeLast(3)}"
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReaderExternalLookupAction(val title: String) {
|
||||
DICTIONARY("Dictionary"),
|
||||
TRANSLATE("Translate"),
|
||||
SEARCH("Search")
|
||||
}
|
||||
|
||||
fun externalLookupUrl(action: ReaderExternalLookupAction, text: String): String {
|
||||
val encoded = text.trim().urlEncoded()
|
||||
return when (action) {
|
||||
ReaderExternalLookupAction.DICTIONARY -> "https://www.google.com/search?q=define+$encoded"
|
||||
ReaderExternalLookupAction.TRANSLATE -> "https://translate.google.com/?sl=auto&tl=en&text=$encoded&op=translate"
|
||||
ReaderExternalLookupAction.SEARCH -> "https://www.google.com/search?q=$encoded"
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderAutoScrollState(
|
||||
val enabled: Boolean = false,
|
||||
val speed: Float = 36f
|
||||
) {
|
||||
fun sanitized(): ReaderAutoScrollState {
|
||||
return copy(speed = speed.coerceIn(12f, 160f))
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReaderTtsReadScope(val label: String) {
|
||||
PAGE("Page"),
|
||||
CHAPTER("Chapter"),
|
||||
BOOK("From here")
|
||||
}
|
||||
|
||||
data class ReaderTtsChunk(
|
||||
val index: Int,
|
||||
val pageIndex: Int,
|
||||
val chapterIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val text: String,
|
||||
val startOffset: Int,
|
||||
val endOffset: Int,
|
||||
val sourceCfi: String? = null,
|
||||
val spokenText: String = text
|
||||
) {
|
||||
fun toLocator(): ReaderLocator {
|
||||
val boundedEnd = endOffset.coerceAtLeast(startOffset)
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = startOffset,
|
||||
endOffset = boundedEnd,
|
||||
textQuote = text,
|
||||
cfi = sourceCfi ?: "desktop:$chapterIndex:$startOffset:$boundedEnd"
|
||||
)
|
||||
}
|
||||
|
||||
fun toHighlight(sessionId: Long): UserHighlight {
|
||||
val locator = toLocator()
|
||||
return UserHighlight(
|
||||
id = "tts_${sessionId}_$index",
|
||||
cfi = locator.cfi.orEmpty(),
|
||||
text = text,
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = chapterIndex,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTtsProgress(
|
||||
val sessionId: Long = 0L,
|
||||
val scope: ReaderTtsReadScope = ReaderTtsReadScope.PAGE,
|
||||
val chunks: List<ReaderTtsChunk> = emptyList(),
|
||||
val currentChunkIndex: Int = -1
|
||||
) {
|
||||
val currentChunk: ReaderTtsChunk?
|
||||
get() = chunks.getOrNull(currentChunkIndex)
|
||||
|
||||
val isActive: Boolean
|
||||
get() = currentChunk != null
|
||||
|
||||
val currentPositionLabel: String?
|
||||
get() = currentChunk?.let { chunk ->
|
||||
"Part ${currentChunkIndex + 1}/${chunks.size} - ${chunk.chapterTitle.ifBlank { scope.label }}"
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTtsCacheSummary(
|
||||
val cachedChapterCount: Int = 0,
|
||||
val cachedChunkCount: Int = 0,
|
||||
val currentVoiceChunkCount: Int = 0,
|
||||
val totalSizeBytes: Long = 0L,
|
||||
val currentVoiceSizeBytes: Long = 0L
|
||||
) {
|
||||
val hasCachedAudio: Boolean get() = cachedChunkCount > 0
|
||||
val hasCurrentVoiceCachedAudio: Boolean get() = currentVoiceChunkCount > 0
|
||||
|
||||
val currentVoiceLabel: String
|
||||
get() = if (hasCurrentVoiceCachedAudio) {
|
||||
"$currentVoiceChunkCount chunks, ${formatReaderTtsBytes(currentVoiceSizeBytes)}"
|
||||
} else {
|
||||
"No cached chunks for this voice"
|
||||
}
|
||||
}
|
||||
|
||||
object ReaderTtsPlanner {
|
||||
fun chunksForCurrentPage(session: ReaderSessionState): List<ReaderTtsChunk> {
|
||||
val page = session.reader.currentPage ?: return emptyList()
|
||||
return chunksForPages(session.reader.book, listOf(page))
|
||||
}
|
||||
|
||||
fun chunksForCurrentChapter(session: ReaderSessionState): List<ReaderTtsChunk> {
|
||||
val page = session.reader.currentPage ?: return emptyList()
|
||||
return chunksForPages(
|
||||
session.reader.book,
|
||||
session.reader.pages
|
||||
.asSequence()
|
||||
.filter { it.pageIndex >= page.pageIndex && it.chapterIndex == page.chapterIndex }
|
||||
.toList()
|
||||
)
|
||||
}
|
||||
|
||||
fun chunksFromCurrentLocation(session: ReaderSessionState): List<ReaderTtsChunk> {
|
||||
val pageIndex = session.reader.currentPageIndex
|
||||
return chunksForPages(session.reader.book, session.reader.pages.drop(pageIndex.coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
fun chunksForText(
|
||||
text: String,
|
||||
pageIndex: Int,
|
||||
chapterIndex: Int,
|
||||
chapterTitle: String,
|
||||
sourceStartOffset: Int = 0
|
||||
): List<ReaderTtsChunk> {
|
||||
return splitTextIntoRanges(text).mapIndexed { index, range ->
|
||||
ReaderTtsChunk(
|
||||
index = index,
|
||||
pageIndex = pageIndex,
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = chapterTitle,
|
||||
text = range.text,
|
||||
startOffset = sourceStartOffset + range.start,
|
||||
endOffset = sourceStartOffset + range.end
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chunksForPages(book: SharedEpubBook, pages: List<ReaderPage>): List<ReaderTtsChunk> {
|
||||
var nextIndex = 0
|
||||
return pages
|
||||
.groupBy { it.chapterIndex }
|
||||
.entries
|
||||
.sortedBy { (chapterIndex, _) ->
|
||||
pages.indexOfFirst { it.chapterIndex == chapterIndex }.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
||||
}
|
||||
.flatMap { chapterPages ->
|
||||
val chapter = book.chapters.getOrNull(chapterPages.key)
|
||||
val semanticChunks = chapter
|
||||
?.let { chunksForSemanticPages(it, chapterPages.value) }
|
||||
.orEmpty()
|
||||
if (semanticChunks.isNotEmpty()) {
|
||||
semanticChunks
|
||||
} else {
|
||||
chunksForPlainPages(book, chapterPages.value)
|
||||
}
|
||||
}
|
||||
.distinctBy { "${it.sourceCfi}:${it.startOffset}:${it.endOffset}:${it.text}" }
|
||||
.map { it.copy(index = nextIndex++) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun chunksForPlainPages(book: SharedEpubBook, pages: List<ReaderPage>): List<ReaderTtsChunk> {
|
||||
return pages.flatMap { page ->
|
||||
val chapterText = book.chapters
|
||||
.getOrNull(page.chapterIndex)
|
||||
?.normalizedTtsSourceText()
|
||||
.orEmpty()
|
||||
val sourceStartOffset = page.sourceTextStartOffset(chapterText)
|
||||
splitTextIntoRanges(page.text).map { range ->
|
||||
ReaderTtsChunk(
|
||||
index = 0,
|
||||
pageIndex = page.pageIndex,
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
text = range.text,
|
||||
startOffset = sourceStartOffset + range.start,
|
||||
endOffset = sourceStartOffset + range.end
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun chunksForSemanticPages(
|
||||
chapter: SharedEpubChapter,
|
||||
pages: List<ReaderPage>
|
||||
): List<ReaderTtsChunk> {
|
||||
if (chapter.semanticBlocks.isEmpty() || pages.isEmpty()) return emptyList()
|
||||
val ranges = pages.map { it.startOffset to it.endOffset }
|
||||
val textBlocks = chapter.semanticBlocks.semanticTextBlocks()
|
||||
.filter { block ->
|
||||
block.cfi != null &&
|
||||
block.text.isNotBlank() &&
|
||||
ranges.any { (start, end) -> block.intersects(start, end) }
|
||||
}
|
||||
return textBlocks.flatMap { block ->
|
||||
val blockStart = block.startCharOffsetInSource.coerceAtLeast(0)
|
||||
splitTextIntoRanges(block.text).mapNotNull { range ->
|
||||
val chunkStart = blockStart + range.start
|
||||
val chunkEnd = blockStart + range.end
|
||||
if (ranges.none { (start, end) -> chunkStart < end && chunkEnd > start }) return@mapNotNull null
|
||||
val page = pages.firstOrNull { it.intersects(chunkStart, chunkEnd) }
|
||||
?: pages.minByOrNull { kotlin.math.abs(it.startOffset - chunkStart) }
|
||||
?: return@mapNotNull null
|
||||
ReaderTtsChunk(
|
||||
index = 0,
|
||||
pageIndex = page.pageIndex,
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
text = range.text,
|
||||
startOffset = chunkStart,
|
||||
endOffset = chunkEnd,
|
||||
sourceCfi = block.cfi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SemanticBlock>.semanticTextBlocks(): List<SemanticTextBlock> {
|
||||
val blocks = mutableListOf<SemanticTextBlock>()
|
||||
fun visit(block: SemanticBlock) {
|
||||
when (block) {
|
||||
is SemanticTextBlock -> blocks += block
|
||||
is SemanticFlexContainer -> block.children.forEach(::visit)
|
||||
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> cell.content.forEach(::visit) } }
|
||||
is SemanticList -> block.items.forEach(::visit)
|
||||
is SemanticWrappingBlock -> block.paragraphsToWrap.forEach(::visit)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
forEach(::visit)
|
||||
return blocks
|
||||
}
|
||||
|
||||
private fun SemanticTextBlock.intersects(startOffset: Int, endOffset: Int): Boolean {
|
||||
val start = startCharOffsetInSource
|
||||
val end = start + text.length
|
||||
return start < endOffset && end > startOffset
|
||||
}
|
||||
|
||||
private fun ReaderPage.intersects(startOffset: Int, endOffset: Int): Boolean {
|
||||
return startOffset < endOffset && startOffset < this.endOffset && endOffset > this.startOffset
|
||||
}
|
||||
|
||||
private fun ReaderPage.sourceTextStartOffset(chapterText: String): Int {
|
||||
if (chapterText.isBlank()) return startOffset
|
||||
val boundedStart = startOffset.coerceIn(0, chapterText.length)
|
||||
val boundedEnd = endOffset.coerceIn(boundedStart, chapterText.length)
|
||||
val pageSlice = chapterText.substring(boundedStart, boundedEnd)
|
||||
val trimAdjustedStart = boundedStart + pageSlice.leadingWhitespaceLength()
|
||||
val exactTextStart = text
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { needle ->
|
||||
chapterText.indexOf(needle, startIndex = boundedStart)
|
||||
.takeIf { found -> found >= boundedStart && found + needle.length <= boundedEnd }
|
||||
}
|
||||
if (exactTextStart != null) return exactTextStart
|
||||
val trimmedTextStart = text
|
||||
.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { needle ->
|
||||
chapterText.indexOf(needle, startIndex = boundedStart)
|
||||
.takeIf { found -> found >= boundedStart && found + needle.length <= boundedEnd }
|
||||
}
|
||||
return trimmedTextStart ?: trimAdjustedStart
|
||||
}
|
||||
|
||||
private fun String.leadingWhitespaceLength(): Int {
|
||||
return length - trimStart().length
|
||||
}
|
||||
|
||||
private fun SharedEpubChapter.normalizedTtsSourceText(): String {
|
||||
return plainText
|
||||
.replace("\r\n", "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun splitTextIntoRanges(
|
||||
text: String,
|
||||
maxLength: Int = READER_TTS_CHUNK_MAX_LENGTH
|
||||
): List<ReaderTtsTextRange> {
|
||||
val sourceStart = text.indexOfFirst { !it.isWhitespace() }
|
||||
if (sourceStart < 0) return emptyList()
|
||||
val sourceEnd = text.indexOfLast { !it.isWhitespace() } + 1
|
||||
val source = text.substring(sourceStart, sourceEnd)
|
||||
val sentenceRanges = androidStyleSentenceRanges(source, sourceStart)
|
||||
if (sentenceRanges.isEmpty()) return emptyList()
|
||||
|
||||
val chunks = mutableListOf<ReaderTtsTextRange>()
|
||||
var currentText = StringBuilder()
|
||||
var currentStart = -1
|
||||
var currentEnd = -1
|
||||
fun flushCurrent() {
|
||||
if (currentText.isNotEmpty() && currentStart >= 0 && currentEnd >= currentStart) {
|
||||
chunks += ReaderTtsTextRange(
|
||||
text = currentText.toString(),
|
||||
start = currentStart,
|
||||
end = currentStart + currentText.length
|
||||
)
|
||||
}
|
||||
currentText = StringBuilder()
|
||||
currentStart = -1
|
||||
currentEnd = -1
|
||||
}
|
||||
|
||||
for (sentence in sentenceRanges) {
|
||||
if (sentence.text.length > maxLength) {
|
||||
flushCurrent()
|
||||
chunks += sentence
|
||||
continue
|
||||
}
|
||||
if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) {
|
||||
flushCurrent()
|
||||
currentText.append(sentence.text)
|
||||
currentStart = sentence.start
|
||||
currentEnd = sentence.end
|
||||
} else {
|
||||
if (currentText.isNotEmpty()) currentText.append(" ")
|
||||
currentText.append(sentence.text)
|
||||
if (currentStart < 0) currentStart = sentence.start
|
||||
currentEnd = sentence.end
|
||||
}
|
||||
}
|
||||
flushCurrent()
|
||||
return chunks
|
||||
}
|
||||
|
||||
private fun androidStyleSentenceRanges(source: String, sourceOffset: Int): List<ReaderTtsTextRange> {
|
||||
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
|
||||
val ranges = mutableListOf<ReaderTtsTextRange>()
|
||||
var start = 0
|
||||
sentenceBoundaryRegex.findAll(source).forEach { match ->
|
||||
val end = match.range.first
|
||||
if (end > start) {
|
||||
source.substring(start, end)
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { sentence ->
|
||||
ranges += ReaderTtsTextRange(
|
||||
text = sentence,
|
||||
start = sourceOffset + start,
|
||||
end = sourceOffset + end
|
||||
)
|
||||
}
|
||||
}
|
||||
start = match.range.last + 1
|
||||
}
|
||||
if (start < source.length) {
|
||||
val sentence = source.substring(start)
|
||||
if (sentence.isNotBlank()) {
|
||||
ranges += ReaderTtsTextRange(
|
||||
text = sentence,
|
||||
start = sourceOffset + start,
|
||||
end = sourceOffset + source.length
|
||||
)
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
private data class ReaderTtsTextRange(
|
||||
val text: String,
|
||||
val start: Int,
|
||||
val end: Int
|
||||
)
|
||||
}
|
||||
|
||||
data class ReaderCloudTtsState(
|
||||
val isAvailable: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isPaused: Boolean = false,
|
||||
val statusMessage: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val progress: ReaderTtsProgress = ReaderTtsProgress(),
|
||||
val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary()
|
||||
)
|
||||
|
||||
data class ReaderAiResultState(
|
||||
val title: String? = null,
|
||||
val text: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
) {
|
||||
val hasContent: Boolean get() = text.isNotBlank() || errorMessage != null || isLoading
|
||||
}
|
||||
|
||||
data class ReaderExtrasState(
|
||||
val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(),
|
||||
val cloudTts: ReaderCloudTtsState = ReaderCloudTtsState(),
|
||||
val aiResult: ReaderAiResultState = ReaderAiResultState()
|
||||
)
|
||||
|
||||
data class ReaderByokTextRequest(
|
||||
val model: ReaderAiModelOption,
|
||||
val apiKey: String,
|
||||
val systemInstruction: String,
|
||||
val userPrompt: String,
|
||||
val temperature: Double,
|
||||
val maxTokens: Int
|
||||
)
|
||||
|
||||
sealed interface ReaderByokTextRequestResult {
|
||||
data class Ready(val request: ReaderByokTextRequest) : ReaderByokTextRequestResult
|
||||
data class MissingModel(val featureName: String) : ReaderByokTextRequestResult
|
||||
data class MissingKey(val provider: String) : ReaderByokTextRequestResult
|
||||
data object Hidden : ReaderByokTextRequestResult
|
||||
}
|
||||
|
||||
object ReaderByokTextRequests {
|
||||
fun build(
|
||||
settings: ReaderAiByokSettings,
|
||||
feature: ReaderAiFeature,
|
||||
text: String,
|
||||
context: String? = null
|
||||
): ReaderByokTextRequestResult {
|
||||
val sanitized = settings.sanitized()
|
||||
if (sanitized.hideReaderAiFeatures) return ReaderByokTextRequestResult.Hidden
|
||||
val model = readerAiModelById(sanitized.modelIdFor(feature))
|
||||
?: return ReaderByokTextRequestResult.MissingModel(feature.displayName)
|
||||
val apiKey = sanitized.apiKeyFor(model.provider)
|
||||
if (apiKey.isBlank()) return ReaderByokTextRequestResult.MissingKey(model.provider)
|
||||
val prompt = promptFor(feature, text, context)
|
||||
return ReaderByokTextRequestResult.Ready(
|
||||
ReaderByokTextRequest(
|
||||
model = model,
|
||||
apiKey = apiKey,
|
||||
systemInstruction = prompt.systemInstruction,
|
||||
userPrompt = prompt.userPrompt,
|
||||
temperature = prompt.temperature,
|
||||
maxTokens = prompt.maxTokens
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun promptFor(feature: ReaderAiFeature, text: String, context: String?): ReaderPrompt {
|
||||
return when (feature) {
|
||||
ReaderAiFeature.DEFINE -> ReaderPrompt(
|
||||
systemInstruction = "You are a concise reading dictionary. Define the selected word or passage, explain nuance in context, and avoid unrelated commentary.",
|
||||
userPrompt = buildString {
|
||||
context?.takeIf { it.isNotBlank() }?.let {
|
||||
append("Context:\n")
|
||||
append(it.trim().take(3000))
|
||||
append("\n\n")
|
||||
}
|
||||
append("Selection:\n")
|
||||
append(text.trim())
|
||||
},
|
||||
temperature = 0.15,
|
||||
maxTokens = 1024
|
||||
)
|
||||
|
||||
ReaderAiFeature.SUMMARIZE -> ReaderPrompt(
|
||||
systemInstruction = "You are an expert reading assistant. Summarize the provided passage clearly and concisely. Focus on the main ideas, plot points, and useful context. Do not add a preamble.",
|
||||
userPrompt = text.trim(),
|
||||
temperature = 0.2,
|
||||
maxTokens = 4096
|
||||
)
|
||||
|
||||
ReaderAiFeature.RECAP -> ReaderPrompt(
|
||||
systemInstruction = "You are a reading assistant creating a recap up to the reader's current position. Synthesize prior context and current text into a cohesive recap. Conclude exactly where the reader is positioned. Do not add a preamble.",
|
||||
userPrompt = text.trim(),
|
||||
temperature = 0.3,
|
||||
maxTokens = 4096
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderPrompt(
|
||||
val systemInstruction: String,
|
||||
val userPrompt: String,
|
||||
val temperature: Double,
|
||||
val maxTokens: Int
|
||||
)
|
||||
|
||||
object ReaderContextExtractor {
|
||||
fun currentPageText(session: ReaderSessionState, maxChars: Int = 6000): String {
|
||||
return session.reader.currentPage?.text.orEmpty().trim().take(maxChars)
|
||||
}
|
||||
|
||||
fun currentChapterText(session: ReaderSessionState, maxChars: Int = 20_000): String {
|
||||
val chapterIndex = session.reader.currentPage?.chapterIndex ?: return currentPageText(session, maxChars)
|
||||
return session.reader.book.chapters
|
||||
.getOrNull(chapterIndex)
|
||||
?.plainText
|
||||
.orEmpty()
|
||||
.trim()
|
||||
.take(maxChars)
|
||||
}
|
||||
|
||||
fun textBeforeCurrentLocation(session: ReaderSessionState, maxChars: Int = 24_000): String {
|
||||
val page = session.reader.currentPage ?: return ""
|
||||
val builder = StringBuilder()
|
||||
session.reader.book.chapters.forEachIndexed { chapterIndex, chapter ->
|
||||
when {
|
||||
chapterIndex < page.chapterIndex -> {
|
||||
builder.append(chapter.title).append('\n')
|
||||
builder.append(chapter.plainText.trim()).append("\n\n")
|
||||
}
|
||||
chapterIndex == page.chapterIndex -> {
|
||||
builder.append(chapter.title).append('\n')
|
||||
builder.append(chapter.plainText.take(page.endOffset.coerceAtMost(chapter.plainText.length)).trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.toString().trim().takeLast(maxChars)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.urlEncoded(): String {
|
||||
val bytes = toByteArray(Charsets.UTF_8)
|
||||
val builder = StringBuilder()
|
||||
bytes.forEach { raw ->
|
||||
val value = raw.toInt() and 0xFF
|
||||
val char = value.toChar()
|
||||
when {
|
||||
value in 'A'.code..'Z'.code ||
|
||||
value in 'a'.code..'z'.code ||
|
||||
value in '0'.code..'9'.code ||
|
||||
char in "-_.~" -> builder.append(char)
|
||||
char == ' ' -> builder.append('+')
|
||||
else -> builder.append('%').append(value.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
data class ReaderMarkdownDocument(
|
||||
val blocks: List<ReaderMarkdownBlock>
|
||||
)
|
||||
|
||||
sealed interface ReaderMarkdownBlock {
|
||||
data class Heading(val level: Int, val text: String) : ReaderMarkdownBlock
|
||||
data class Paragraph(val text: String) : ReaderMarkdownBlock
|
||||
data class ListItems(val ordered: Boolean, val items: List<String>) : ReaderMarkdownBlock
|
||||
data class CodeBlock(val text: String) : ReaderMarkdownBlock
|
||||
data class Quote(val text: String) : ReaderMarkdownBlock
|
||||
}
|
||||
|
||||
object ReaderMarkdownParser {
|
||||
fun parse(markdown: String): ReaderMarkdownDocument {
|
||||
val lines = markdown.replace("\r\n", "\n").split('\n')
|
||||
val blocks = mutableListOf<ReaderMarkdownBlock>()
|
||||
val paragraph = mutableListOf<String>()
|
||||
var index = 0
|
||||
|
||||
fun flushParagraph() {
|
||||
if (paragraph.isNotEmpty()) {
|
||||
blocks += ReaderMarkdownBlock.Paragraph(paragraph.joinToString(" ").trim())
|
||||
paragraph.clear()
|
||||
}
|
||||
}
|
||||
|
||||
while (index < lines.size) {
|
||||
val line = lines[index]
|
||||
val trimmed = line.trim()
|
||||
when {
|
||||
trimmed.isBlank() -> {
|
||||
flushParagraph()
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith("```") -> {
|
||||
flushParagraph()
|
||||
val code = mutableListOf<String>()
|
||||
index += 1
|
||||
while (index < lines.size && !lines[index].trim().startsWith("```")) {
|
||||
code += lines[index]
|
||||
index += 1
|
||||
}
|
||||
if (index < lines.size) index += 1
|
||||
blocks += ReaderMarkdownBlock.CodeBlock(code.joinToString("\n").trimEnd())
|
||||
}
|
||||
|
||||
trimmed.headingLevel() != null -> {
|
||||
flushParagraph()
|
||||
val level = trimmed.headingLevel() ?: 1
|
||||
blocks += ReaderMarkdownBlock.Heading(
|
||||
level = level,
|
||||
text = trimmed.drop(level).trim()
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith(">") -> {
|
||||
flushParagraph()
|
||||
val quote = mutableListOf<String>()
|
||||
while (index < lines.size && lines[index].trim().startsWith(">")) {
|
||||
quote += lines[index].trim().removePrefix(">").trim()
|
||||
index += 1
|
||||
}
|
||||
blocks += ReaderMarkdownBlock.Quote(quote.joinToString(" ").trim())
|
||||
}
|
||||
|
||||
trimmed.unorderedListText() != null || trimmed.orderedListText() != null -> {
|
||||
flushParagraph()
|
||||
val ordered = trimmed.orderedListText() != null
|
||||
val items = mutableListOf<String>()
|
||||
while (index < lines.size) {
|
||||
val itemLine = lines[index].trim()
|
||||
val item = if (ordered) itemLine.orderedListText() else itemLine.unorderedListText()
|
||||
if (item == null) break
|
||||
items += item
|
||||
index += 1
|
||||
}
|
||||
blocks += ReaderMarkdownBlock.ListItems(ordered = ordered, items = items)
|
||||
}
|
||||
|
||||
else -> {
|
||||
paragraph += trimmed
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushParagraph()
|
||||
return ReaderMarkdownDocument(blocks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.headingLevel(): Int? {
|
||||
val count = takeWhile { it == '#' }.length
|
||||
return count.takeIf { it in 1..6 && getOrNull(it) == ' ' }
|
||||
}
|
||||
|
||||
private fun String.unorderedListText(): String? {
|
||||
return if (length > 2 && first() in listOf('-', '*', '+') && this[1] == ' ') {
|
||||
drop(2).trim()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.orderedListText(): String? {
|
||||
val dotIndex = indexOf('.')
|
||||
if (dotIndex <= 0 || dotIndex + 1 >= length || this[dotIndex + 1] != ' ') return null
|
||||
return take(dotIndex).takeIf { number -> number.all { it.isDigit() } }
|
||||
?.let { drop(dotIndex + 2).trim() }
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
private val DefaultReaderBottomToolIds: Set<String>
|
||||
get() = setOf(
|
||||
ReaderTool.SLIDER.id,
|
||||
ReaderTool.TOC.id,
|
||||
ReaderTool.FORMAT.id,
|
||||
ReaderTool.SEARCH.id,
|
||||
ReaderTool.AI_FEATURES.id,
|
||||
ReaderTool.TTS_CONTROLS.id
|
||||
)
|
||||
|
||||
enum class ReaderTool(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val supportsDesktopQuickAction: Boolean = false
|
||||
) {
|
||||
DICTIONARY("dictionary", "External Apps", "Top Bar", supportsDesktopQuickAction = true),
|
||||
THEME("theme", "Theme Settings", "Top Bar", supportsDesktopQuickAction = true),
|
||||
SLIDER("slider", "Navigation Slider", "Bottom Bar"),
|
||||
TOC("toc", "Sidebar", "Bottom Bar"),
|
||||
FORMAT("format", "Text Formatting", "Bottom Bar"),
|
||||
SEARCH("search", "Search", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
AI_FEATURES("ai_features", "AI Features", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar", supportsDesktopQuickAction = true),
|
||||
READING_MODE("reading_mode", "Reading Mode", "Overflow Menu"),
|
||||
BOOKMARK("bookmark", "Bookmark", "Overflow Menu", supportsDesktopQuickAction = true),
|
||||
TAP_TO_TURN("tap_to_turn", "Tap to Turn Pages", "Overflow Menu"),
|
||||
VOLUME_SCROLL("volume_scroll", "Volume Button Scrolling", "Overflow Menu"),
|
||||
PAGE_TURN_ANIM("page_turn_anim", "Realistic Page Turns", "Overflow Menu"),
|
||||
KEEP_SCREEN_ON("keep_screen_on", "Keep Screen On", "Overflow Menu"),
|
||||
VISUAL_OPTIONS("visual_options", "Visual Options", "Overflow Menu"),
|
||||
AUTO_SCROLL("auto_scroll", "Auto Scroll", "Overflow Menu", supportsDesktopQuickAction = true),
|
||||
TTS_SETTINGS("tts_settings", "TTS Voice Settings", "Overflow Menu"),
|
||||
TTS_REPLACEMENTS("tts_replacements", "TTS Word Replacements", "Overflow Menu");
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): ReaderTool? {
|
||||
return entries.firstOrNull { it.id == id || it.name == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderToolbarPreferences(
|
||||
val hiddenToolIds: Set<String> = emptySet(),
|
||||
val toolOrder: List<ReaderTool> = ReaderTool.entries.toList(),
|
||||
val bottomToolIds: Set<String> = DefaultReaderBottomToolIds
|
||||
) {
|
||||
fun sanitized(): ReaderToolbarPreferences {
|
||||
val orderedTools = (toolOrder + ReaderTool.entries.toList())
|
||||
.distinct()
|
||||
.filter { it in ReaderTool.entries }
|
||||
val knownToolIds = ReaderTool.entries.mapTo(mutableSetOf()) { it.id }
|
||||
return copy(
|
||||
hiddenToolIds = hiddenToolIds.filterTo(mutableSetOf()) { it in knownToolIds },
|
||||
toolOrder = orderedTools,
|
||||
bottomToolIds = bottomToolIds.filterTo(mutableSetOf()) { it in knownToolIds }
|
||||
)
|
||||
}
|
||||
|
||||
fun isVisible(tool: ReaderTool): Boolean {
|
||||
return tool.id !in hiddenToolIds
|
||||
}
|
||||
|
||||
fun isBottom(tool: ReaderTool): Boolean {
|
||||
return tool.id in bottomToolIds
|
||||
}
|
||||
|
||||
fun withVisibility(tool: ReaderTool, hidden: Boolean): ReaderToolbarPreferences {
|
||||
val nextHidden = if (hidden) hiddenToolIds + tool.id else hiddenToolIds - tool.id
|
||||
return copy(hiddenToolIds = nextHidden).sanitized()
|
||||
}
|
||||
|
||||
fun withBottomPlacement(tool: ReaderTool, bottom: Boolean): ReaderToolbarPreferences {
|
||||
val nextBottom = if (bottom) bottomToolIds + tool.id else bottomToolIds - tool.id
|
||||
return copy(bottomToolIds = nextBottom).sanitized()
|
||||
}
|
||||
|
||||
fun withToolOrder(order: List<ReaderTool>): ReaderToolbarPreferences {
|
||||
return copy(toolOrder = order).sanitized()
|
||||
}
|
||||
|
||||
fun orderedVisibleTools(): List<ReaderTool> {
|
||||
return sanitized().toolOrder.filter(::isVisible)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val defaultBottomToolIds: Set<String> get() = DefaultReaderBottomToolIds
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
data class ReaderTtsReplacementRule(
|
||||
val id: String,
|
||||
val from: String,
|
||||
val to: String,
|
||||
val enabled: Boolean = true,
|
||||
val isRegex: Boolean = false,
|
||||
val matchCase: Boolean = false,
|
||||
val wholeWord: Boolean = true,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementBookSettings(
|
||||
val localRulesEnabled: Boolean = true,
|
||||
val globalRulesEnabled: Boolean = true,
|
||||
val disabledGlobalRuleIds: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementPreferences(
|
||||
val isEnabled: Boolean = true,
|
||||
val globalRules: List<ReaderTtsReplacementRule> = emptyList(),
|
||||
val bookRules: Map<String, List<ReaderTtsReplacementRule>> = emptyMap(),
|
||||
val bookSettings: Map<String, ReaderTtsReplacementBookSettings> = emptyMap(),
|
||||
) {
|
||||
fun settingsForBook(bookId: String?): ReaderTtsReplacementBookSettings {
|
||||
return bookSettings[bookId.orEmpty()] ?: ReaderTtsReplacementBookSettings()
|
||||
}
|
||||
|
||||
fun rulesForBook(bookId: String?): List<ReaderTtsReplacementRule> {
|
||||
return bookRules[bookId.orEmpty()].orEmpty()
|
||||
}
|
||||
|
||||
fun activeRulesForBook(bookId: String?): List<ReaderTtsReplacementRule> {
|
||||
if (!isEnabled) return emptyList()
|
||||
val settings = settingsForBook(bookId)
|
||||
val inherited = if (settings.globalRulesEnabled) {
|
||||
globalRules.filter { it.id !in settings.disabledGlobalRuleIds }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val local = if (settings.localRulesEnabled) rulesForBook(bookId) else emptyList()
|
||||
return inherited + local
|
||||
}
|
||||
|
||||
fun withBookSettings(
|
||||
bookId: String?,
|
||||
settings: ReaderTtsReplacementBookSettings,
|
||||
): ReaderTtsReplacementPreferences {
|
||||
return copy(bookSettings = bookSettings + (bookId.orEmpty() to settings))
|
||||
}
|
||||
|
||||
fun withBookRules(
|
||||
bookId: String?,
|
||||
rules: List<ReaderTtsReplacementRule>,
|
||||
): ReaderTtsReplacementPreferences {
|
||||
return copy(bookRules = bookRules + (bookId.orEmpty() to rules))
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTtsReplacementValidation(
|
||||
val isValid: Boolean,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementError(
|
||||
val ruleId: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class ReaderTtsReplacementApplyResult(
|
||||
val text: String,
|
||||
val appliedRuleIds: List<String> = emptyList(),
|
||||
val errors: List<ReaderTtsReplacementError> = emptyList(),
|
||||
) {
|
||||
val hasUnmappableChanges: Boolean
|
||||
get() = appliedRuleIds.isNotEmpty()
|
||||
}
|
||||
|
||||
object ReaderTtsReplacementEngine {
|
||||
fun validate(rule: ReaderTtsReplacementRule): ReaderTtsReplacementValidation {
|
||||
if (rule.from.isBlank()) {
|
||||
return ReaderTtsReplacementValidation(isValid = false, message = "Enter text to replace.")
|
||||
}
|
||||
if (!rule.isRegex) {
|
||||
return ReaderTtsReplacementValidation(isValid = true)
|
||||
}
|
||||
return runCatching { rule.toRegex() }
|
||||
.fold(
|
||||
onSuccess = { ReaderTtsReplacementValidation(isValid = true) },
|
||||
onFailure = {
|
||||
ReaderTtsReplacementValidation(
|
||||
isValid = false,
|
||||
message = it.message ?: "This regex is not valid.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): ReaderTtsReplacementApplyResult {
|
||||
if (text.isEmpty() || !preferences.isEnabled) {
|
||||
return ReaderTtsReplacementApplyResult(text = text)
|
||||
}
|
||||
|
||||
var current = text
|
||||
val applied = mutableListOf<String>()
|
||||
val errors = mutableListOf<ReaderTtsReplacementError>()
|
||||
|
||||
preferences.activeRulesForBook(bookId).forEach { rule ->
|
||||
if (!rule.enabled || rule.from.isBlank()) return@forEach
|
||||
val regex = runCatching { rule.toRegex() }
|
||||
.onFailure {
|
||||
errors += ReaderTtsReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid regex.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
val replacement = if (rule.isRegex) rule.to else Regex.escapeReplacement(rule.to)
|
||||
val next = runCatching { regex.replace(current, replacement) }
|
||||
.onFailure {
|
||||
errors += ReaderTtsReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid replacement.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
if (next != current) {
|
||||
applied += rule.id
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
return ReaderTtsReplacementApplyResult(
|
||||
text = current,
|
||||
appliedRuleIds = applied,
|
||||
errors = errors,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderTtsReplacementRule.toRegex(): Regex {
|
||||
val source = if (isRegex) from else Regex.escape(from)
|
||||
val boundedSource = if (wholeWord) {
|
||||
"""(?<![\p{L}\p{N}_])(?:$source)(?![\p{L}\p{N}_])"""
|
||||
} else {
|
||||
source
|
||||
}
|
||||
val options = if (matchCase) emptySet() else setOf(RegexOption.IGNORE_CASE)
|
||||
return Regex(pattern = boundedSource, options = options)
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderTtsChunk.withTtsReplacements(
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): ReaderTtsChunk {
|
||||
val result = ReaderTtsReplacementEngine.apply(
|
||||
text = text,
|
||||
preferences = preferences,
|
||||
bookId = bookId,
|
||||
)
|
||||
return copy(spokenText = result.text)
|
||||
}
|
||||
|
||||
fun List<ReaderTtsChunk>.withTtsReplacements(
|
||||
preferences: ReaderTtsReplacementPreferences,
|
||||
bookId: String? = null,
|
||||
): List<ReaderTtsChunk> = map { it.withTtsReplacements(preferences, bookId) }
|
||||
|
||||
object ReaderTtsReplacementSuggestions {
|
||||
val presets: List<ReaderTtsReplacementRule> = listOf(
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_dr",
|
||||
from = "Dr.",
|
||||
to = "Doctor",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_mr",
|
||||
from = "Mr.",
|
||||
to = "Mister",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_mrs",
|
||||
from = "Mrs.",
|
||||
to = "Missus",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_ms",
|
||||
from = "Ms.",
|
||||
to = "Miss",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_vs",
|
||||
from = "vs.",
|
||||
to = "versus",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_et_al",
|
||||
from = "et al.",
|
||||
to = "and others",
|
||||
wholeWord = false,
|
||||
),
|
||||
ReaderTtsReplacementRule(
|
||||
id = "suggestion_initials",
|
||||
from = """\b([A-Z])\.\s*([A-Z])\.""",
|
||||
to = "\$1 \$2",
|
||||
isRegex = true,
|
||||
wholeWord = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
object ReaderTtsReplacementPreferencesJson {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = false
|
||||
}
|
||||
|
||||
fun encode(preferences: ReaderTtsReplacementPreferences): String {
|
||||
return json.encodeToString(JsonElement.serializer(), toJsonElement(preferences))
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(raw: String?): ReaderTtsReplacementPreferences {
|
||||
if (raw.isNullOrBlank()) return ReaderTtsReplacementPreferences()
|
||||
return runCatching {
|
||||
fromJsonElement(json.parseToJsonElement(raw))
|
||||
}.getOrNull() ?: ReaderTtsReplacementPreferences()
|
||||
}
|
||||
|
||||
fun toJsonElement(preferences: ReaderTtsReplacementPreferences): JsonElement {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"isEnabled" to JsonPrimitive(preferences.isEnabled),
|
||||
"globalRules" to rulesToJson(preferences.globalRules),
|
||||
"bookRules" to JsonObject(
|
||||
preferences.bookRules.mapValues { (_, rules) -> rulesToJson(rules) as JsonElement },
|
||||
),
|
||||
"bookSettings" to JsonObject(
|
||||
preferences.bookSettings.mapValues { (_, settings) -> settingsToJson(settings) as JsonElement },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun fromJsonElement(element: JsonElement?): ReaderTtsReplacementPreferences {
|
||||
val root = element as? JsonObject ?: return ReaderTtsReplacementPreferences()
|
||||
val bookRules = root["bookRules"]?.jsonObjectOrNull()
|
||||
?.mapValues { (_, value) -> value.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty() }
|
||||
.orEmpty()
|
||||
val bookSettings = root["bookSettings"]?.jsonObjectOrNull()
|
||||
?.mapValues { (_, value) -> settingsFromJson(value) }
|
||||
.orEmpty()
|
||||
return ReaderTtsReplacementPreferences(
|
||||
isEnabled = root.booleanValue("isEnabled") ?: true,
|
||||
globalRules = root["globalRules"]?.jsonArrayOrNull()?.mapNotNull(::ruleFromJson).orEmpty(),
|
||||
bookRules = bookRules,
|
||||
bookSettings = bookSettings,
|
||||
)
|
||||
}
|
||||
|
||||
private fun rulesToJson(rules: List<ReaderTtsReplacementRule>): JsonArray {
|
||||
return JsonArray(rules.map(::ruleToJson))
|
||||
}
|
||||
|
||||
private fun ruleToJson(rule: ReaderTtsReplacementRule): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(rule.id),
|
||||
"from" to JsonPrimitive(rule.from),
|
||||
"to" to JsonPrimitive(rule.to),
|
||||
"enabled" to JsonPrimitive(rule.enabled),
|
||||
"isRegex" to JsonPrimitive(rule.isRegex),
|
||||
"matchCase" to JsonPrimitive(rule.matchCase),
|
||||
"wholeWord" to JsonPrimitive(rule.wholeWord),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ruleFromJson(element: JsonElement): ReaderTtsReplacementRule? {
|
||||
val root = element as? JsonObject ?: return null
|
||||
val id = root.stringValue("id") ?: return null
|
||||
val from = root.stringValue("from") ?: return null
|
||||
return ReaderTtsReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = root.stringValue("to").orEmpty(),
|
||||
enabled = root.booleanValue("enabled") ?: true,
|
||||
isRegex = root.booleanValue("isRegex") ?: false,
|
||||
matchCase = root.booleanValue("matchCase") ?: false,
|
||||
wholeWord = root.booleanValue("wholeWord") ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun settingsToJson(settings: ReaderTtsReplacementBookSettings): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"localRulesEnabled" to JsonPrimitive(settings.localRulesEnabled),
|
||||
"globalRulesEnabled" to JsonPrimitive(settings.globalRulesEnabled),
|
||||
"disabledGlobalRuleIds" to JsonArray(settings.disabledGlobalRuleIds.map(::JsonPrimitive)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun settingsFromJson(element: JsonElement): ReaderTtsReplacementBookSettings {
|
||||
val root = element as? JsonObject ?: return ReaderTtsReplacementBookSettings()
|
||||
return ReaderTtsReplacementBookSettings(
|
||||
localRulesEnabled = root.booleanValue("localRulesEnabled") ?: true,
|
||||
globalRulesEnabled = root.booleanValue("globalRulesEnabled") ?: true,
|
||||
disabledGlobalRuleIds = root["disabledGlobalRuleIds"]?.jsonArrayOrNull()
|
||||
?.mapNotNull { it.jsonPrimitiveOrNull()?.contentOrNull }
|
||||
?.toSet()
|
||||
.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringValue(name: String): String? {
|
||||
return get(name)?.jsonPrimitiveOrNull()?.contentOrNull
|
||||
}
|
||||
|
||||
private fun JsonObject.booleanValue(name: String): Boolean? {
|
||||
return get(name)?.jsonPrimitiveOrNull()?.booleanOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
private fun JsonElement.jsonPrimitiveOrNull() = when (this) {
|
||||
is JsonPrimitive -> this
|
||||
JsonNull -> null
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@ data class ImportedBookFile(
|
|||
val name: String,
|
||||
val uriString: String?,
|
||||
val localPath: String?,
|
||||
val size: Long
|
||||
val size: Long,
|
||||
val sourceFolder: String? = null
|
||||
)
|
||||
|
||||
interface BookRepository {
|
||||
|
|
@ -54,5 +55,7 @@ interface AiAdapter {
|
|||
interface TtsAdapter {
|
||||
val isAvailable: Boolean
|
||||
suspend fun speak(text: String)
|
||||
suspend fun pause() = Unit
|
||||
suspend fun resume() = Unit
|
||||
suspend fun stop()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ fun BookItem.isOpdsStream(): Boolean {
|
|||
return path?.startsWith("opds-pse://") == true
|
||||
}
|
||||
|
||||
fun BookItem.matchesSourceFolders(sourceFolders: Set<String>): Boolean {
|
||||
if (sourceFolders.isEmpty()) return true
|
||||
val matchesInAppStorage = IN_APP_STORAGE_SOURCE in sourceFolders &&
|
||||
sourceFolder == null &&
|
||||
!isOpdsStream()
|
||||
return matchesInAppStorage || sourceFolder in sourceFolders
|
||||
}
|
||||
|
||||
private fun formatDecimal(value: Double, decimals: Int): String {
|
||||
val factor = 10.0.pow(decimals)
|
||||
val rounded = (value * factor).roundToInt() / factor
|
||||
|
|
|
|||
|
|
@ -0,0 +1,618 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
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.reader.ReaderBookmark
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reader.SharedReaderTextAlign
|
||||
|
||||
data class SharedLibrarySnapshot(
|
||||
val books: List<BookItem> = emptyList(),
|
||||
val shelfRecords: List<ShelfRecord> = emptyList(),
|
||||
val shelfRefs: List<BookShelfRef> = emptyList(),
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val customFonts: List<CustomFontItem> = emptyList(),
|
||||
val syncedFolders: List<SyncedFolder> = emptyList(),
|
||||
val recentFilesLimit: Int = 12,
|
||||
val isTabsEnabled: Boolean = false,
|
||||
val openTabIds: List<String> = emptyList(),
|
||||
val activeTabBookId: String? = null,
|
||||
val pinnedHomeBookIds: Set<String> = emptySet(),
|
||||
val pinnedLibraryBookIds: Set<String> = emptySet(),
|
||||
val useStrictFileFilter: Boolean = false,
|
||||
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
||||
val appTextDimFactorLight: Float = 1.0f,
|
||||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: Color? = null,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val readerTtsReplacementPreferences: ReaderTtsReplacementPreferences = ReaderTtsReplacementPreferences()
|
||||
)
|
||||
|
||||
object SharedLibrarySnapshotJson {
|
||||
private const val SCHEMA_VERSION = 10
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(rawJson: String): SharedLibrarySnapshot {
|
||||
val root = runCatching {
|
||||
json.parseToJsonElement(rawJson).jsonObject
|
||||
}.getOrNull() ?: return SharedLibrarySnapshot()
|
||||
|
||||
val schemaVersion = root.int("schemaVersion", 1)
|
||||
val openTabIds = root.stringArray("openTabIds")
|
||||
return SharedLibrarySnapshot(
|
||||
books = root.array("books")
|
||||
.mapNotNull { it.asBookItemOrNull() }
|
||||
.migrateLegacyRecentState(schemaVersion, openTabIds),
|
||||
shelfRecords = root.array("shelves").mapNotNull { it.asShelfRecordOrNull() },
|
||||
shelfRefs = root.array("bookShelfRefs").mapNotNull { it.asBookShelfRefOrNull() },
|
||||
tags = root.array("tags").mapNotNull { it.asTagOrNull() },
|
||||
customFonts = root.array("customFonts").mapNotNull { it.asCustomFontItemOrNull() },
|
||||
syncedFolders = root.array("syncedFolders").mapNotNull { it.asSyncedFolderOrNull() },
|
||||
recentFilesLimit = root.int("recentFilesLimit", 12),
|
||||
isTabsEnabled = root.boolean("isTabsEnabled", false),
|
||||
openTabIds = openTabIds,
|
||||
activeTabBookId = root.string("activeTabBookId"),
|
||||
pinnedHomeBookIds = root.stringArray("pinnedHomeBookIds").toSet(),
|
||||
pinnedLibraryBookIds = root.stringArray("pinnedLibraryBookIds").toSet(),
|
||||
useStrictFileFilter = root.boolean("useStrictFileFilter", false),
|
||||
appThemeMode = root.string("appThemeMode")
|
||||
?.let { runCatching { AppThemeMode.valueOf(it) }.getOrNull() }
|
||||
?: AppThemeMode.SYSTEM,
|
||||
appContrastOption = root.string("appContrastOption")
|
||||
?.let { runCatching { AppContrastOption.valueOf(it) }.getOrNull() }
|
||||
?: AppContrastOption.STANDARD,
|
||||
appTextDimFactorLight = root.float("appTextDimFactorLight")
|
||||
?: root.float("appTextDimFactor")
|
||||
?: 1.0f,
|
||||
appTextDimFactorDark = root.float("appTextDimFactorDark")
|
||||
?: root.float("appTextDimFactor")
|
||||
?: 1.0f,
|
||||
appSeedColor = root.int("appSeedColor")?.let { Color(it) },
|
||||
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
|
||||
readerToolbarPreferences = root["readerToolbarPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderToolbarPreferencesOrNull()
|
||||
?: ReaderToolbarPreferences(),
|
||||
readerHighlightPalette = root["readerHighlightPalette"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderHighlightPaletteOrNull()
|
||||
?: ReaderHighlightPalette(),
|
||||
readerTtsReplacementPreferences = root["readerTtsReplacementPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.let { ReaderTtsReplacementPreferencesJson.fromJsonElement(it) }
|
||||
?: ReaderTtsReplacementPreferences()
|
||||
)
|
||||
}
|
||||
|
||||
fun encode(snapshot: SharedLibrarySnapshot): String {
|
||||
val root = JsonObject(
|
||||
mapOf(
|
||||
"schemaVersion" to JsonPrimitive(SCHEMA_VERSION),
|
||||
"books" to JsonArray(snapshot.books.map { it.toJsonObject() }),
|
||||
"shelves" to JsonArray(snapshot.shelfRecords.map { it.toJsonObject() }),
|
||||
"bookShelfRefs" to JsonArray(snapshot.shelfRefs.map { it.toJsonObject() }),
|
||||
"tags" to JsonArray(snapshot.tags.map { it.toJsonObject() }),
|
||||
"customFonts" to JsonArray(snapshot.customFonts.map { it.toJsonObject() }),
|
||||
"syncedFolders" to JsonArray(snapshot.syncedFolders.map { it.toJsonObject() }),
|
||||
"recentFilesLimit" to JsonPrimitive(snapshot.recentFilesLimit),
|
||||
"isTabsEnabled" to JsonPrimitive(snapshot.isTabsEnabled),
|
||||
"openTabIds" to snapshot.openTabIds.asJsonArray(),
|
||||
"activeTabBookId" to snapshot.activeTabBookId.asJson(),
|
||||
"pinnedHomeBookIds" to snapshot.pinnedHomeBookIds.toList().asJsonArray(),
|
||||
"pinnedLibraryBookIds" to snapshot.pinnedLibraryBookIds.toList().asJsonArray(),
|
||||
"useStrictFileFilter" to JsonPrimitive(snapshot.useStrictFileFilter),
|
||||
"appThemeMode" to JsonPrimitive(snapshot.appThemeMode.name),
|
||||
"appContrastOption" to JsonPrimitive(snapshot.appContrastOption.name),
|
||||
"appTextDimFactorLight" to JsonPrimitive(snapshot.appTextDimFactorLight),
|
||||
"appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark),
|
||||
"appSeedColor" to snapshot.appSeedColor.asJson(),
|
||||
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
|
||||
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
|
||||
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
|
||||
"readerTtsReplacementPreferences" to ReaderTtsReplacementPreferencesJson.toJsonElement(
|
||||
snapshot.readerTtsReplacementPreferences,
|
||||
)
|
||||
)
|
||||
)
|
||||
return json.encodeToString(JsonElement.serializer(), root)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): List<JsonElement> {
|
||||
return runCatching { this[name]?.jsonArray?.toList().orEmpty() }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun JsonObject.stringArray(name: String): List<String> {
|
||||
return array(name).mapNotNull { element ->
|
||||
runCatching { element.jsonPrimitive.content }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String, fallback: Long = 0L): Long {
|
||||
return runCatching { this[name]?.jsonPrimitive?.longOrNull }.getOrNull() ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonObject.nullableLong(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.content?.toIntOrNull() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String, fallback: Int): Int {
|
||||
return int(name) ?: fallback
|
||||
}
|
||||
|
||||
private fun JsonObject.float(name: String): Float? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.floatOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.double(name: String): Double? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.doubleOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String, fallback: Boolean): Boolean {
|
||||
return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull() ?: fallback
|
||||
}
|
||||
|
||||
private fun List<BookItem>.migrateLegacyRecentState(schemaVersion: Int, openTabIds: List<String>): List<BookItem> {
|
||||
if (schemaVersion >= 3) return this
|
||||
val openedBookIds = openTabIds.toSet()
|
||||
return map { book ->
|
||||
if (book.isRecent && !book.hasReaderFootprint(openedBookIds)) {
|
||||
book.copy(isRecent = false)
|
||||
} else {
|
||||
book
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BookItem.hasReaderFootprint(openedBookIds: Set<String>): Boolean {
|
||||
return id in openedBookIds ||
|
||||
lastPageIndex != null ||
|
||||
(progressPercentage ?: 0f) > 0f ||
|
||||
readerSettings != null ||
|
||||
readerBookmarks.isNotEmpty() ||
|
||||
readerHighlights.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookItemOrNull(): BookItem? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val id = obj.string("id") ?: return null
|
||||
val displayName = obj.string("displayName") ?: return null
|
||||
val type = obj.string("type")?.let { runCatching { FileType.valueOf(it) }.getOrNull() } ?: FileType.UNKNOWN
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = obj.string("path"),
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = obj.long("timestamp"),
|
||||
coverImagePath = obj.string("coverImagePath"),
|
||||
title = obj.string("title"),
|
||||
author = obj.string("author"),
|
||||
progressPercentage = obj.float("progressPercentage"),
|
||||
isRecent = obj.boolean("isRecent", true),
|
||||
fileSize = obj.long("fileSize"),
|
||||
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"),
|
||||
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
|
||||
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asShelfRecordOrNull(): ShelfRecord? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ShelfRecord(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
isSmart = obj.boolean("isSmart", false),
|
||||
smartRulesJson = obj.string("smartRulesJson")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asBookShelfRefOrNull(): BookShelfRef? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return BookShelfRef(
|
||||
bookId = obj.string("bookId") ?: return null,
|
||||
shelfId = obj.string("shelfId") ?: return null,
|
||||
addedAt = obj.long("addedAt")
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asTagOrNull(): Tag? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return Tag(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
color = runCatching {
|
||||
obj["color"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.content?.toIntOrNull()
|
||||
}.getOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asCustomFontItemOrNull(): CustomFontItem? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return CustomFontItem(
|
||||
id = obj.string("id") ?: return null,
|
||||
displayName = obj.string("displayName") ?: return null,
|
||||
fileName = obj.string("fileName") ?: return null,
|
||||
fileExtension = obj.string("fileExtension") ?: return null,
|
||||
path = obj.string("path") ?: return null,
|
||||
timestamp = obj.long("timestamp"),
|
||||
isDeleted = obj.boolean("isDeleted", false)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return SyncedFolder(
|
||||
uriString = obj.string("uriString") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
lastScanTime = obj.long("lastScanTime"),
|
||||
allowedFileTypes = obj.stringArray("allowedFileTypes")
|
||||
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
|
||||
.toSet()
|
||||
.ifEmpty { FileType.entries.toSet() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return CustomAppTheme(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
seedColor = obj.int("seedColor")?.let { Color(it) } ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookItem.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"path" to path.asJson(),
|
||||
"type" to JsonPrimitive(type.name),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"coverImagePath" to coverImagePath.asJson(),
|
||||
"title" to title.asJson(),
|
||||
"author" to author.asJson(),
|
||||
"progressPercentage" to progressPercentage.asJson(),
|
||||
"isRecent" to JsonPrimitive(isRecent),
|
||||
"fileSize" to JsonPrimitive(fileSize),
|
||||
"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(),
|
||||
"readerSettings" to readerSettings.asJson(),
|
||||
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ShelfRecord.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"isSmart" to JsonPrimitive(isSmart),
|
||||
"smartRulesJson" to smartRulesJson.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun BookShelfRef.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"shelfId" to JsonPrimitive(shelfId),
|
||||
"addedAt" to JsonPrimitive(addedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Tag.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"color" to color.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustomFontItem.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"displayName" to JsonPrimitive(displayName),
|
||||
"fileName" to JsonPrimitive(fileName),
|
||||
"fileExtension" to JsonPrimitive(fileExtension),
|
||||
"path" to JsonPrimitive(path),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"isDeleted" to JsonPrimitive(isDeleted)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun SyncedFolder.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"uriString" to JsonPrimitive(uriString),
|
||||
"name" to JsonPrimitive(name),
|
||||
"lastScanTime" to JsonPrimitive(lastScanTime),
|
||||
"allowedFileTypes" to allowedFileTypes.map { it.name }.sorted().asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustomAppTheme.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"seedColor" to JsonPrimitive(seedColor.toArgb())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Int?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Long?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Color?.asJson(): JsonElement = this?.let { JsonPrimitive(it.toArgb()) } ?: JsonNull
|
||||
|
||||
private fun List<String>.asJsonArray(): JsonArray {
|
||||
return JsonArray(map { JsonPrimitive(it) })
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val defaults = ReaderSettings()
|
||||
return ReaderSettings(
|
||||
fontSize = obj.int("fontSize") ?: defaults.fontSize,
|
||||
lineSpacing = obj.float("lineSpacing") ?: defaults.lineSpacing,
|
||||
margin = obj.int("margin") ?: defaults.margin,
|
||||
darkMode = obj.boolean("darkMode", defaults.darkMode),
|
||||
readingMode = obj.string("readingMode")
|
||||
?.let { runCatching { ReaderReadingMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.readingMode,
|
||||
textAlign = obj.string("textAlign")
|
||||
?.let { runCatching { SharedReaderTextAlign.valueOf(it) }.getOrNull() }
|
||||
?: defaults.textAlign,
|
||||
pageWidth = obj.int("pageWidth") ?: defaults.pageWidth,
|
||||
fontFamily = obj.string("fontFamily") ?: defaults.fontFamily,
|
||||
paragraphSpacing = obj.float("paragraphSpacing") ?: defaults.paragraphSpacing,
|
||||
imageScale = obj.float("imageScale") ?: defaults.imageScale,
|
||||
horizontalMargin = obj.int("horizontalMargin"),
|
||||
verticalMargin = obj.int("verticalMargin"),
|
||||
themeId = obj.string("themeId"),
|
||||
textureId = obj.string("textureId"),
|
||||
textureAlpha = obj.float("textureAlpha") ?: defaults.textureAlpha,
|
||||
customFontPath = obj.string("customFontPath"),
|
||||
backgroundColorArgb = obj.nullableLong("backgroundColorArgb"),
|
||||
textColorArgb = obj.nullableLong("textColorArgb"),
|
||||
systemUiMode = obj.string("systemUiMode")
|
||||
?.let { runCatching { SystemUiMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.systemUiMode,
|
||||
pageInfoMode = obj.string("pageInfoMode")
|
||||
?.let { runCatching { PageInfoMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageInfoMode,
|
||||
pageInfoPosition = obj.string("pageInfoPosition")
|
||||
?.let { runCatching { PageInfoPosition.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageInfoPosition,
|
||||
seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation),
|
||||
chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderToolbarPreferencesOrNull(): ReaderToolbarPreferences? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val order = obj.stringArray("toolOrder").mapNotNull(ReaderTool::fromId)
|
||||
val bottomToolIds = if (obj["bottomToolIds"] == null) {
|
||||
ReaderToolbarPreferences.defaultBottomToolIds
|
||||
} else {
|
||||
obj.stringArray("bottomToolIds").toSet()
|
||||
}
|
||||
return ReaderToolbarPreferences(
|
||||
hiddenToolIds = obj.stringArray("hiddenToolIds").toSet(),
|
||||
toolOrder = order.ifEmpty { ReaderTool.entries.toList() },
|
||||
bottomToolIds = bottomToolIds
|
||||
).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderHighlightPaletteOrNull(): ReaderHighlightPalette? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val colors = obj.stringArray("colorIds")
|
||||
.mapNotNull { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId || it.name == colorId } }
|
||||
return ReaderHighlightPalette(colors = colors).sanitized()
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderBookmarkOrNull(): ReaderBookmark? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val pageIndex = obj.int("pageIndex") ?: return null
|
||||
return ReaderBookmark(
|
||||
id = obj.string("id") ?: return null,
|
||||
pageIndex = pageIndex,
|
||||
chapterTitle = obj.string("chapterTitle") ?: "",
|
||||
preview = obj.string("preview") ?: "",
|
||||
locator = obj["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(pageIndex = pageIndex, textQuote = obj.string("preview") ?: "")
|
||||
?: ReaderLocator(
|
||||
pageIndex = pageIndex,
|
||||
textQuote = obj.string("preview") ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderHighlightOrNull(): UserHighlight? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val cfi = obj.string("cfi") ?: return null
|
||||
val text = obj.string("text") ?: return null
|
||||
val chapterIndex = obj.int("chapterIndex") ?: return null
|
||||
val color = obj.string("colorId")
|
||||
?.let { colorId -> HighlightColor.entries.firstOrNull { it.id == colorId } }
|
||||
?: HighlightColor.YELLOW
|
||||
return UserHighlight(
|
||||
id = obj.string("id") ?: return null,
|
||||
cfi = cfi,
|
||||
text = text,
|
||||
color = color,
|
||||
chapterIndex = chapterIndex,
|
||||
note = obj.string("note")?.takeIf { it.isNotBlank() },
|
||||
locator = obj["locator"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderLocatorOrNull()
|
||||
?.withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
?: ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderLocator(
|
||||
chapterIndex = obj.int("chapterIndex"),
|
||||
chapterId = obj.string("chapterId"),
|
||||
href = obj.string("href"),
|
||||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderSettings?.asJson(): JsonElement {
|
||||
val settings = this ?: return JsonNull
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"fontSize" to JsonPrimitive(settings.fontSize),
|
||||
"lineSpacing" to JsonPrimitive(settings.lineSpacing),
|
||||
"margin" to JsonPrimitive(settings.margin),
|
||||
"darkMode" to JsonPrimitive(settings.darkMode),
|
||||
"readingMode" to JsonPrimitive(settings.readingMode.name),
|
||||
"textAlign" to JsonPrimitive(settings.textAlign.name),
|
||||
"pageWidth" to JsonPrimitive(settings.pageWidth),
|
||||
"fontFamily" to JsonPrimitive(settings.fontFamily),
|
||||
"paragraphSpacing" to JsonPrimitive(settings.paragraphSpacing),
|
||||
"imageScale" to JsonPrimitive(settings.imageScale),
|
||||
"horizontalMargin" to settings.horizontalMargin.asJson(),
|
||||
"verticalMargin" to settings.verticalMargin.asJson(),
|
||||
"themeId" to settings.themeId.asJson(),
|
||||
"textureId" to settings.textureId.asJson(),
|
||||
"textureAlpha" to JsonPrimitive(settings.textureAlpha),
|
||||
"customFontPath" to settings.customFontPath.asJson(),
|
||||
"backgroundColorArgb" to settings.backgroundColorArgb.asJson(),
|
||||
"textColorArgb" to settings.textColorArgb.asJson(),
|
||||
"systemUiMode" to JsonPrimitive(settings.systemUiMode.name),
|
||||
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
|
||||
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
|
||||
"seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation),
|
||||
"chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderToolbarPreferences.toJsonObject(): JsonObject {
|
||||
val sanitized = sanitized()
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"hiddenToolIds" to sanitized.hiddenToolIds.toList().sorted().asJsonArray(),
|
||||
"toolOrder" to sanitized.toolOrder.map { it.id }.asJsonArray(),
|
||||
"bottomToolIds" to sanitized.bottomToolIds.toList().sorted().asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderHighlightPalette.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"colorIds" to sanitized().colors.map { it.id }.asJsonArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"pageIndex" to JsonPrimitive(pageIndex),
|
||||
"chapterTitle" to JsonPrimitive(chapterTitle),
|
||||
"preview" to JsonPrimitive(preview),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun UserHighlight.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"cfi" to JsonPrimitive(cfi),
|
||||
"text" to JsonPrimitive(text),
|
||||
"colorId" to JsonPrimitive(color.id),
|
||||
"chapterIndex" to JsonPrimitive(chapterIndex),
|
||||
"note" to note.asJson(),
|
||||
"locator" to locator.toJsonObject()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
chapterIndex?.let { put("chapterIndex", JsonPrimitive(it)) }
|
||||
chapterId?.let { put("chapterId", JsonPrimitive(it)) }
|
||||
href?.let { put("href", JsonPrimitive(it)) }
|
||||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import com.aryan.reader.shared.reader.ReaderEngine
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
|
||||
fun LibraryState.reduce(action: LibraryAction): LibraryState {
|
||||
return when (action) {
|
||||
is LibraryAction.SearchChanged -> copy(searchQuery = action.query)
|
||||
|
|
@ -56,7 +59,122 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
is AppAction.NavigationRequested -> this
|
||||
is AppAction.AppThemeChanged -> copy(appThemeMode = action.mode)
|
||||
is AppAction.AppContrastChanged -> copy(appContrastOption = action.option)
|
||||
is AppAction.AppTextDimFactorLightChanged -> copy(appTextDimFactorLight = action.factor.coerceIn(0.3f, 1.0f))
|
||||
is AppAction.AppTextDimFactorDarkChanged -> copy(appTextDimFactorDark = action.factor.coerceIn(0.3f, 1.0f))
|
||||
is AppAction.AppSeedColorChanged -> copy(appSeedColor = action.color)
|
||||
is AppAction.CustomAppThemeAdded -> {
|
||||
val updatedThemes = customAppThemes.filterNot { it.id == action.theme.id } + action.theme
|
||||
copy(customAppThemes = updatedThemes, appSeedColor = action.theme.seedColor)
|
||||
}
|
||||
is AppAction.CustomAppThemeDeleted -> {
|
||||
val updatedThemes = customAppThemes.filterNot { it.id == action.themeId }
|
||||
val shouldClearSeed = appSeedColor != null && updatedThemes.none { it.seedColor == appSeedColor }
|
||||
copy(
|
||||
customAppThemes = updatedThemes,
|
||||
appSeedColor = if (shouldClearSeed) null else appSeedColor
|
||||
)
|
||||
}
|
||||
is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
|
||||
is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
|
||||
is AppAction.TabsEnabledChanged -> copy(
|
||||
isTabsEnabled = action.enabled,
|
||||
openTabIds = if (action.enabled) openTabIds else emptyList(),
|
||||
activeTabBookId = if (action.enabled) activeTabBookId else null
|
||||
)
|
||||
is AppAction.BookTabOpened -> {
|
||||
val bookId = action.bookId.trim()
|
||||
if (bookId.isBlank()) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
isTabsEnabled = true,
|
||||
openTabIds = (openTabIds - bookId) + bookId,
|
||||
activeTabBookId = bookId
|
||||
)
|
||||
}
|
||||
}
|
||||
is AppAction.BookTabClosed -> {
|
||||
val remaining = openTabIds.filterNot { it == action.bookId }
|
||||
copy(
|
||||
openTabIds = remaining,
|
||||
activeTabBookId = if (activeTabBookId == action.bookId) remaining.lastOrNull() else activeTabBookId
|
||||
)
|
||||
}
|
||||
AppAction.AllTabsClosed -> copy(openTabIds = emptyList(), activeTabBookId = null)
|
||||
is AppAction.HomePinToggled -> copy(
|
||||
pinnedHomeBookIds = if (action.bookId in pinnedHomeBookIds) {
|
||||
pinnedHomeBookIds - action.bookId
|
||||
} else {
|
||||
pinnedHomeBookIds + action.bookId
|
||||
}
|
||||
)
|
||||
is AppAction.LibraryPinToggled -> copy(
|
||||
pinnedLibraryBookIds = if (action.bookId in pinnedLibraryBookIds) {
|
||||
pinnedLibraryBookIds - action.bookId
|
||||
} else {
|
||||
pinnedLibraryBookIds + action.bookId
|
||||
}
|
||||
)
|
||||
is AppAction.ReaderToolbarPreferencesChanged -> copy(
|
||||
readerToolbarPreferences = action.preferences.sanitized()
|
||||
)
|
||||
is AppAction.ReaderToolVisibilityChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withVisibility(action.tool, action.hidden)
|
||||
)
|
||||
is AppAction.ReaderToolPlacementChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withBottomPlacement(action.tool, action.bottom)
|
||||
)
|
||||
is AppAction.ReaderToolOrderChanged -> copy(
|
||||
readerToolbarPreferences = readerToolbarPreferences.withToolOrder(action.toolOrder)
|
||||
)
|
||||
is AppAction.ReaderHighlightPaletteChanged -> copy(
|
||||
readerHighlightPalette = action.palette.sanitized()
|
||||
)
|
||||
is AppAction.ReaderTtsReplacementPreferencesChanged -> copy(
|
||||
readerTtsReplacementPreferences = action.preferences
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ReaderSessionState.reduce(action: ReaderAction, readerEngine: ReaderEngine): ReaderSessionState {
|
||||
return when (action) {
|
||||
ReaderAction.NextPage -> readerEngine.next(this)
|
||||
ReaderAction.PreviousPage -> readerEngine.previous(this)
|
||||
is ReaderAction.GoToPage -> readerEngine.goToPage(this, action.pageIndex)
|
||||
is ReaderAction.GoToPageNumber -> readerEngine.goToPageNumber(this, action.pageNumber)
|
||||
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.VisiblePageChanged -> readerEngine.syncVisiblePage(this, action.pageIndex, action.locator)
|
||||
is ReaderAction.GoToSearchResult -> readerEngine.goToSearchResult(this, action.resultIndex)
|
||||
is ReaderAction.SearchChanged -> readerEngine.search(this, action.query)
|
||||
ReaderAction.SearchOpened -> readerEngine.openSearch(this)
|
||||
ReaderAction.SearchClosed -> readerEngine.closeSearch(this)
|
||||
ReaderAction.SearchResultsPanelToggled -> readerEngine.toggleSearchResultsPanel(this)
|
||||
is ReaderAction.SearchOptionsChanged -> readerEngine.updateSearchOptions(this, action.options)
|
||||
ReaderAction.NextSearchResult -> readerEngine.nextSearchResult(this)
|
||||
ReaderAction.PreviousSearchResult -> readerEngine.previousSearchResult(this)
|
||||
ReaderAction.ToggleBookmark -> readerEngine.toggleBookmark(this)
|
||||
is ReaderAction.ToggleBookmarkAtLocator -> readerEngine.toggleBookmarkAtLocator(
|
||||
state = this,
|
||||
locator = action.locator,
|
||||
chapterTitle = action.title,
|
||||
preview = action.preview
|
||||
)
|
||||
is ReaderAction.SettingsChanged -> readerEngine.updateSettings(this, action.settings)
|
||||
is ReaderAction.RenderModeChanged -> readerEngine.updateSettings(
|
||||
this,
|
||||
reader.settings.copy(readingMode = action.renderMode.toReaderReadingMode())
|
||||
)
|
||||
is ReaderAction.ThemeChanged -> readerEngine.updateSettings(this, action.theme.toReaderSettings(reader.settings))
|
||||
is ReaderAction.FormatChanged -> readerEngine.updateSettings(this, action.settings.toReaderSettings(reader.settings))
|
||||
is ReaderAction.HighlightCreated -> readerEngine.upsertHighlight(this, action.highlight)
|
||||
is ReaderAction.HighlightUpdated -> readerEngine.updateHighlight(
|
||||
state = this,
|
||||
highlightId = action.highlightId,
|
||||
color = action.color,
|
||||
note = action.note
|
||||
)
|
||||
is ReaderAction.HighlightDeleted -> readerEngine.deleteHighlight(this, action.highlightId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
enum class SmartField {
|
||||
TITLE,
|
||||
AUTHOR,
|
||||
PROGRESS,
|
||||
FILE_TYPE,
|
||||
FOLDER,
|
||||
TAG
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SmartOperator {
|
||||
EQUALS,
|
||||
CONTAINS,
|
||||
GREATER_THAN,
|
||||
LESS_THAN
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SmartRule(
|
||||
val field: SmartField,
|
||||
val operator: SmartOperator,
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SmartCollectionDefinition(
|
||||
val matchAll: Boolean = true,
|
||||
val rules: List<SmartRule> = emptyList()
|
||||
)
|
||||
|
||||
object SmartCollectionEngine {
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
|
||||
|
||||
fun fromJson(rawJson: String?): SmartCollectionDefinition? {
|
||||
if (rawJson.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
json.decodeFromString<SmartCollectionDefinition>(rawJson)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun evaluate(book: BookItem, definition: SmartCollectionDefinition): Boolean {
|
||||
if (definition.rules.isEmpty()) return false
|
||||
|
||||
val results = definition.rules.map { rule ->
|
||||
when (rule.field) {
|
||||
SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
|
||||
SmartField.AUTHOR -> evaluateString(book.author.orEmpty(), rule)
|
||||
SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
|
||||
SmartField.FOLDER -> evaluateString(book.sourceFolder.orEmpty(), rule)
|
||||
SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
|
||||
SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
|
||||
}
|
||||
}
|
||||
return if (definition.matchAll) results.all { it } else results.any { it }
|
||||
}
|
||||
|
||||
private fun evaluateString(target: String, rule: SmartRule): Boolean {
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
|
||||
SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
|
||||
SmartOperator.GREATER_THAN,
|
||||
SmartOperator.LESS_THAN -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
|
||||
val ruleValue = rule.value.toFloatOrNull() ?: return false
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> target == ruleValue
|
||||
SmartOperator.GREATER_THAN -> target > ruleValue
|
||||
SmartOperator.LESS_THAN -> target < ruleValue
|
||||
SmartOperator.CONTAINS -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateTags(tags: List<String>, rule: SmartRule): Boolean {
|
||||
return when (rule.operator) {
|
||||
SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
|
||||
SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
|
||||
SmartOperator.GREATER_THAN,
|
||||
SmartOperator.LESS_THAN -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
object SharedOpdsCatalogs {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun defaultCatalogs(idFactory: () -> String): List<OpdsCatalog> {
|
||||
return listOf(
|
||||
OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = "Project Gutenberg",
|
||||
url = "https://m.gutenberg.org/ebooks.opds/",
|
||||
isDefault = true
|
||||
),
|
||||
OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = "Standard Ebooks",
|
||||
url = "https://standardebooks.org/feeds/opds",
|
||||
isDefault = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun decode(rawJson: String?): List<OpdsCatalog> {
|
||||
if (rawJson.isNullOrBlank()) return emptyList()
|
||||
return runCatching {
|
||||
json.parseToJsonElement(rawJson)
|
||||
.jsonArray
|
||||
.mapNotNull { it.asCatalogOrNull() }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun decodeOrSeed(rawJson: String?, idFactory: () -> String): List<OpdsCatalog> {
|
||||
return decode(rawJson).ifEmpty { defaultCatalogs(idFactory) }
|
||||
}
|
||||
|
||||
fun encode(catalogs: List<OpdsCatalog>): String {
|
||||
val array = JsonArray(catalogs.map { it.toJsonObject() })
|
||||
return json.encodeToString(JsonElement.serializer(), array)
|
||||
}
|
||||
|
||||
fun addCatalog(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
title: String,
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?,
|
||||
idFactory: () -> String
|
||||
): List<OpdsCatalog> {
|
||||
val normalizedTitle = title.trim()
|
||||
val normalizedUrl = url.trim()
|
||||
if (normalizedTitle.isBlank() || normalizedUrl.isBlank()) return catalogs
|
||||
return catalogs + OpdsCatalog(
|
||||
id = idFactory(),
|
||||
title = normalizedTitle,
|
||||
url = normalizedUrl,
|
||||
username = username.normalizedCredential(),
|
||||
password = password.normalizedCredential()
|
||||
)
|
||||
}
|
||||
|
||||
fun updateCatalog(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
id: String,
|
||||
title: String,
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?
|
||||
): List<OpdsCatalog> {
|
||||
return catalogs.map { catalog ->
|
||||
if (catalog.id != id || catalog.isDefault) {
|
||||
catalog
|
||||
} else {
|
||||
catalog.copy(
|
||||
title = title.trim(),
|
||||
url = url.trim(),
|
||||
username = username.normalizedCredential(),
|
||||
password = password.normalizedCredential()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCatalog(catalogs: List<OpdsCatalog>, id: String): List<OpdsCatalog> {
|
||||
val catalog = catalogs.firstOrNull { it.id == id }
|
||||
if (catalog?.isDefault == true) return catalogs
|
||||
return catalogs.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
private fun JsonElement.asCatalogOrNull(): OpdsCatalog? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val id = obj.string("id") ?: return null
|
||||
val title = obj.string("title") ?: return null
|
||||
val url = obj.string("url") ?: return null
|
||||
return OpdsCatalog(
|
||||
id = id,
|
||||
title = title,
|
||||
url = url,
|
||||
isDefault = obj.boolean("isDefault") ?: false,
|
||||
username = obj.string("username").normalizedCredential(),
|
||||
password = obj.string("password").normalizedCredential()
|
||||
)
|
||||
}
|
||||
|
||||
private fun OpdsCatalog.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(id))
|
||||
put("title", JsonPrimitive(title))
|
||||
put("url", JsonPrimitive(url))
|
||||
put("isDefault", JsonPrimitive(isDefault))
|
||||
put("username", username?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("password", password?.let(::JsonPrimitive) ?: JsonNull)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
val value = this[name]?.takeUnless { it is JsonNull } ?: return null
|
||||
return runCatching { value.jsonPrimitive.contentOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun String?.normalizedCredential(): String? {
|
||||
return this?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
class SharedOpdsController(
|
||||
private val repository: SharedOpdsRepository,
|
||||
private val idFactory: () -> String
|
||||
) {
|
||||
private val urlStack = mutableListOf<String>()
|
||||
|
||||
var state: SharedOpdsScreenState = SharedOpdsScreenState(catalogs = repository.loadCatalogs())
|
||||
private set
|
||||
|
||||
fun reloadCatalogs(): SharedOpdsScreenState {
|
||||
state = state.copy(catalogs = repository.loadCatalogs())
|
||||
return state
|
||||
}
|
||||
|
||||
fun addCatalog(title: String, url: String, username: String?, password: String?): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.addCatalog(
|
||||
catalogs = repository.loadCatalogs(),
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
password = password,
|
||||
idFactory = idFactory
|
||||
)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(catalogs = nextCatalogs)
|
||||
return state
|
||||
}
|
||||
|
||||
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.updateCatalog(
|
||||
catalogs = repository.loadCatalogs(),
|
||||
id = id,
|
||||
title = title,
|
||||
url = url,
|
||||
username = username,
|
||||
password = password
|
||||
)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(
|
||||
catalogs = nextCatalogs,
|
||||
currentCatalog = state.currentCatalog?.let { current ->
|
||||
nextCatalogs.firstOrNull { it.id == current.id } ?: current
|
||||
}
|
||||
)
|
||||
return state
|
||||
}
|
||||
|
||||
fun removeCatalog(id: String): SharedOpdsScreenState {
|
||||
val nextCatalogs = SharedOpdsCatalogs.removeCatalog(repository.loadCatalogs(), id)
|
||||
repository.saveCatalogs(nextCatalogs)
|
||||
state = state.copy(catalogs = nextCatalogs)
|
||||
return state
|
||||
}
|
||||
|
||||
suspend fun openCatalog(catalog: OpdsCatalog, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
urlStack.clear()
|
||||
state = state.copy(searchUrlTemplate = null, currentCatalog = catalog)
|
||||
fetchUrl(catalog.url, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun openFeedUrl(url: String, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
fetchUrl(url, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun loadNextPage(emit: (SharedOpdsScreenState) -> Unit) {
|
||||
val nextUrl = state.currentFeed?.nextUrl ?: return
|
||||
if (state.isLoading) return
|
||||
fetchUrl(nextUrl, isPagination = true, emit = emit)
|
||||
}
|
||||
|
||||
suspend fun navigateBack(emit: (SharedOpdsScreenState) -> Unit): Boolean {
|
||||
return if (urlStack.size > 1) {
|
||||
urlStack.removeAt(urlStack.lastIndex)
|
||||
val previousUrl = urlStack.removeAt(urlStack.lastIndex)
|
||||
fetchUrl(previousUrl, isPagination = false, emit = emit)
|
||||
true
|
||||
} else {
|
||||
urlStack.clear()
|
||||
state = state.copy(
|
||||
isViewingCatalog = false,
|
||||
currentFeed = null,
|
||||
searchUrlTemplate = null,
|
||||
currentCatalog = null
|
||||
)
|
||||
emit(state)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String, emit: (SharedOpdsScreenState) -> Unit) {
|
||||
val searchLink = state.searchUrlTemplate ?: return
|
||||
if (query.isBlank()) return
|
||||
val catalog = state.currentCatalog
|
||||
state = state.copy(isLoading = true, errorMessage = null)
|
||||
emit(state)
|
||||
val finalUrl = runCatching {
|
||||
SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
|
||||
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
state = state.copy(isLoading = false, errorMessage = "Failed to search catalog: ${error.message}")
|
||||
emit(state)
|
||||
return
|
||||
}
|
||||
fetchUrl(finalUrl, isPagination = false, emit = emit)
|
||||
}
|
||||
|
||||
fun clearError(): SharedOpdsScreenState {
|
||||
state = state.copy(errorMessage = null)
|
||||
return state
|
||||
}
|
||||
|
||||
fun updateDownloadState(entryId: String, downloadState: SharedOpdsDownloadState?): SharedOpdsScreenState {
|
||||
val nextMap = if (downloadState == null) {
|
||||
state.downloadingState - entryId
|
||||
} else {
|
||||
state.downloadingState + (entryId to downloadState)
|
||||
}
|
||||
state = state.copy(downloadingState = nextMap)
|
||||
return state
|
||||
}
|
||||
|
||||
private suspend fun fetchUrl(
|
||||
url: String,
|
||||
isPagination: Boolean,
|
||||
emit: (SharedOpdsScreenState) -> Unit
|
||||
) {
|
||||
val catalog = state.currentCatalog
|
||||
state = state.copy(isLoading = true, errorMessage = null, isViewingCatalog = true)
|
||||
emit(state)
|
||||
|
||||
val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
|
||||
result.onSuccess { newFeed ->
|
||||
val template = newFeed.searchUrl ?: state.searchUrlTemplate
|
||||
state = if (isPagination) {
|
||||
val currentEntries = state.currentFeed?.entries.orEmpty()
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
|
||||
searchUrlTemplate = template
|
||||
)
|
||||
} else {
|
||||
if (urlStack.isEmpty() || urlStack.last() != url) {
|
||||
urlStack.add(url)
|
||||
}
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentFeed = newFeed,
|
||||
searchUrlTemplate = template
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
state = state.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load feed: ${error.message ?: "unknown error"}"
|
||||
)
|
||||
}
|
||||
emit(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
data class OpdsCatalog(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val isDefault: Boolean = false,
|
||||
val username: String? = null,
|
||||
val password: String? = null
|
||||
)
|
||||
|
||||
data class OpdsFacet(
|
||||
val title: String,
|
||||
val group: String,
|
||||
val url: String,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class OpdsFeed(
|
||||
val title: String,
|
||||
val entries: List<OpdsEntry>,
|
||||
val nextUrl: String?,
|
||||
val searchUrl: String? = null,
|
||||
val facets: List<OpdsFacet> = emptyList()
|
||||
)
|
||||
|
||||
data class OpdsAuthor(
|
||||
val name: String,
|
||||
val url: String?
|
||||
)
|
||||
|
||||
data class OpdsAcquisition(
|
||||
val url: String,
|
||||
val mimeType: String
|
||||
) {
|
||||
val formatName: String
|
||||
get() = when {
|
||||
mimeType.contains("epub", ignoreCase = true) -> "EPUB"
|
||||
mimeType.contains("pdf", ignoreCase = true) -> "PDF"
|
||||
mimeType.contains("markdown", ignoreCase = true) ||
|
||||
mimeType.contains("text/x-markdown", ignoreCase = true) -> "MD"
|
||||
mimeType.contains("html", ignoreCase = true) ||
|
||||
mimeType.contains("xhtml", ignoreCase = true) -> "HTML"
|
||||
mimeType.contains("mobi", ignoreCase = true) ||
|
||||
mimeType.contains("x-mobipocket-ebook", ignoreCase = true) -> "MOBI"
|
||||
mimeType.contains("fictionbook", ignoreCase = true) ||
|
||||
mimeType.contains("fb2", ignoreCase = true) -> "FB2"
|
||||
mimeType.contains("cbz", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook", ignoreCase = true) -> "CBZ"
|
||||
mimeType.contains("cbr", ignoreCase = true) ||
|
||||
mimeType.contains("rar", ignoreCase = true) -> "CBR"
|
||||
mimeType.contains("txt", ignoreCase = true) ||
|
||||
mimeType.contains("text/plain", ignoreCase = true) -> "TXT"
|
||||
else -> mimeType.substringAfterLast("/").uppercase()
|
||||
}
|
||||
|
||||
val priority: Int
|
||||
get() = when (formatName) {
|
||||
"EPUB" -> 5
|
||||
"PDF" -> 4
|
||||
"MOBI" -> 3
|
||||
"FB2", "MD", "HTML" -> 2
|
||||
"CBZ", "CBR", "CB7" -> 1
|
||||
"TXT" -> 0
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
data class OpdsEntry(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val summary: String?,
|
||||
val authors: List<OpdsAuthor> = emptyList(),
|
||||
val coverUrl: String?,
|
||||
val acquisitions: List<OpdsAcquisition> = emptyList(),
|
||||
val navigationUrl: String?,
|
||||
val publisher: String? = null,
|
||||
val published: String? = null,
|
||||
val language: String? = null,
|
||||
val series: String? = null,
|
||||
val seriesIndex: String? = null,
|
||||
val categories: List<String> = emptyList(),
|
||||
val pseCount: Int? = null,
|
||||
val pseUrlTemplate: String? = null
|
||||
) {
|
||||
val author: String?
|
||||
get() = authors.firstOrNull()?.name
|
||||
|
||||
val bestAcquisition: OpdsAcquisition?
|
||||
get() = acquisitions.maxByOrNull { it.priority }
|
||||
|
||||
val isAcquisition: Boolean
|
||||
get() = acquisitions.isNotEmpty()
|
||||
|
||||
val isNavigation: Boolean
|
||||
get() = navigationUrl != null && acquisitions.isEmpty()
|
||||
|
||||
val isStreamable: Boolean
|
||||
get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
|
||||
}
|
||||
|
||||
data class SharedOpdsDownloadState(
|
||||
val isDownloading: Boolean,
|
||||
val progress: Float? = null
|
||||
)
|
||||
|
||||
data class SharedOpdsScreenState(
|
||||
val catalogs: List<OpdsCatalog> = emptyList(),
|
||||
val currentCatalog: OpdsCatalog? = null,
|
||||
val currentFeed: OpdsFeed? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val isViewingCatalog: Boolean = false,
|
||||
val searchUrlTemplate: String? = null,
|
||||
val downloadingState: Map<String, SharedOpdsDownloadState> = emptyMap()
|
||||
)
|
||||
|
||||
data class OpdsStreamReference(
|
||||
val id: String,
|
||||
val count: Int,
|
||||
val urlTemplate: String,
|
||||
val catalogId: String? = null
|
||||
)
|
||||
|
||||
interface SharedOpdsRepository {
|
||||
fun loadCatalogs(): List<OpdsCatalog>
|
||||
fun saveCatalogs(catalogs: List<OpdsCatalog>)
|
||||
suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result<OpdsFeed>
|
||||
suspend fun getSearchTemplate(openSearchUrl: String, username: String? = null, password: String? = null): String?
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
object SharedOpdsSearch {
|
||||
suspend fun buildSearchUrl(
|
||||
searchLink: String,
|
||||
query: String,
|
||||
openSearchTemplateResolver: suspend (String) -> String?
|
||||
): String {
|
||||
val template = if (searchLink.hasSearchTemplateToken()) {
|
||||
searchLink
|
||||
} else {
|
||||
openSearchTemplateResolver(searchLink) ?: searchLink
|
||||
}
|
||||
return expandSearchTemplate(template, query)
|
||||
}
|
||||
|
||||
fun expandSearchTemplate(template: String, query: String): String {
|
||||
val encoded = query.percentEncode()
|
||||
val expandedSearchTerms = template.replace("{searchTerms}", encoded)
|
||||
if (expandedSearchTerms != template) return expandedSearchTerms
|
||||
|
||||
val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template)
|
||||
if (queryTemplate != null) {
|
||||
val operator = queryTemplate.groupValues[1]
|
||||
val variables = queryTemplate.groupValues[2]
|
||||
.split(',')
|
||||
.map { it.substringBefore(':').substringBefore('*').trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
val parameterName = variables.firstOrNull { it.equals("searchTerms", ignoreCase = true) }
|
||||
?: variables.firstOrNull()
|
||||
?: "query"
|
||||
val prefix = template.substringBefore(queryTemplate.value)
|
||||
val suffix = template.substringAfter(queryTemplate.value)
|
||||
val separator = when {
|
||||
operator == "&" -> "&"
|
||||
prefix.contains("?") -> "&"
|
||||
else -> "?"
|
||||
}
|
||||
return "$prefix$separator$parameterName=$encoded$suffix"
|
||||
}
|
||||
|
||||
val expandedQuery = template
|
||||
.replace("{query}", encoded)
|
||||
.replace("{keyword}", encoded)
|
||||
if (expandedQuery != template) return expandedQuery
|
||||
|
||||
val separator = if (template.contains("?")) "&" else "?"
|
||||
return "$template${separator}query=$encoded"
|
||||
}
|
||||
|
||||
private fun String.hasSearchTemplateToken(): Boolean {
|
||||
return contains("{searchTerms}") ||
|
||||
Regex("""\{[?&][^}]+\}""").containsMatchIn(this) ||
|
||||
contains("{query}") ||
|
||||
contains("{keyword}")
|
||||
}
|
||||
}
|
||||
|
||||
object SharedOpdsDownloadNamer {
|
||||
fun resolveExtension(
|
||||
acquisition: OpdsAcquisition,
|
||||
contentDisposition: String?,
|
||||
urlPathSegment: String?
|
||||
): String {
|
||||
val candidates = listOfNotNull(
|
||||
extractContentDispositionFilename(contentDisposition),
|
||||
urlPathSegment
|
||||
)
|
||||
|
||||
candidates.forEach { candidate ->
|
||||
extensionSuffixFromName(candidate.percentDecode())?.let { return it }
|
||||
}
|
||||
|
||||
return when (acquisition.formatName) {
|
||||
"EPUB" -> ".epub"
|
||||
"PDF" -> ".pdf"
|
||||
"MOBI" -> ".mobi"
|
||||
"FB2" -> ".fb2"
|
||||
"CBZ" -> ".cbz"
|
||||
"CBR" -> ".cbr"
|
||||
"CB7" -> ".cb7"
|
||||
"MD" -> ".md"
|
||||
"HTML" -> ".html"
|
||||
"TXT" -> ".txt"
|
||||
else -> ".epub"
|
||||
}
|
||||
}
|
||||
|
||||
fun safeFileStem(title: String, fallback: String = "opds_book"): String {
|
||||
val safe = title
|
||||
.replace(Regex("""[^a-zA-Z0-9._-]+"""), "_")
|
||||
.trim('_')
|
||||
.take(80)
|
||||
return safe.ifBlank { fallback }
|
||||
}
|
||||
|
||||
fun extractContentDispositionFilename(contentDisposition: String?): String? {
|
||||
if (contentDisposition.isNullOrBlank()) return null
|
||||
val encodedFilename = Regex("""filename\*=UTF-8''([^;]+)""", RegexOption.IGNORE_CASE)
|
||||
.find(contentDisposition)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
if (!encodedFilename.isNullOrBlank()) return encodedFilename.trim('"')
|
||||
|
||||
return Regex("""filename="?([^";]+)"?""", RegexOption.IGNORE_CASE)
|
||||
.find(contentDisposition)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.trim()
|
||||
?.trim('"')
|
||||
}
|
||||
|
||||
private fun extensionSuffixFromName(fileName: String?): String? {
|
||||
if (fileName.isNullOrBlank()) return null
|
||||
val cleanName = fileName.substringBefore('?').substringBefore('#')
|
||||
val extension = cleanName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.lowercase()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
if (SharedFileCapabilities.fileTypeForName(cleanName) == com.aryan.reader.shared.FileType.UNKNOWN) return null
|
||||
return ".$extension"
|
||||
}
|
||||
}
|
||||
|
||||
object SharedOpdsStreamUri {
|
||||
private const val SCHEME_PREFIX = "opds-pse://stream"
|
||||
|
||||
fun build(reference: OpdsStreamReference): String {
|
||||
return "$SCHEME_PREFIX?id=${reference.id.percentEncode()}" +
|
||||
"&count=${reference.count}" +
|
||||
"&url=${reference.urlTemplate.percentEncode()}" +
|
||||
reference.catalogId?.let { "&catalogId=${it.percentEncode()}" }.orEmpty()
|
||||
}
|
||||
|
||||
fun parse(uriString: String?): OpdsStreamReference? {
|
||||
if (uriString.isNullOrBlank() || !uriString.startsWith(SCHEME_PREFIX)) return null
|
||||
val query = uriString.substringAfter('?', missingDelimiterValue = "")
|
||||
val params = query.split('&')
|
||||
.mapNotNull { pair ->
|
||||
if (pair.isBlank()) return@mapNotNull null
|
||||
val key = pair.substringBefore('=').percentDecode()
|
||||
val value = pair.substringAfter('=', missingDelimiterValue = "").percentDecode()
|
||||
key to value
|
||||
}
|
||||
.toMap()
|
||||
val id = params["id"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val count = params["count"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null
|
||||
val url = params["url"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
return OpdsStreamReference(
|
||||
id = id,
|
||||
count = count,
|
||||
urlTemplate = url,
|
||||
catalogId = params["catalogId"]?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.percentEncode(): String {
|
||||
val bytes = encodeToByteArray()
|
||||
return buildString(bytes.size) {
|
||||
bytes.forEach { byte ->
|
||||
val value = byte.toInt() and 0xFF
|
||||
val char = value.toChar()
|
||||
if (char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char in "-_.~") {
|
||||
append(char)
|
||||
} else {
|
||||
append('%')
|
||||
append(value.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.percentDecode(): String {
|
||||
val bytes = mutableListOf<Byte>()
|
||||
var index = 0
|
||||
while (index < length) {
|
||||
val char = this[index]
|
||||
if (char == '%' && index + 2 < length) {
|
||||
val value = substring(index + 1, index + 3).toIntOrNull(16)
|
||||
if (value != null) {
|
||||
bytes += value.toByte()
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
val encoded = char.toString().encodeToByteArray()
|
||||
encoded.forEach { bytes += it }
|
||||
index += 1
|
||||
}
|
||||
return bytes.toByteArray().decodeToString()
|
||||
}
|
||||
|
||||
object SharedOpdsText {
|
||||
fun cleanSummary(summary: String?): String {
|
||||
if (summary.isNullOrBlank()) return ""
|
||||
return summary
|
||||
.replace(Regex("""<br\s*/?>""", RegexOption.IGNORE_CASE), "\n")
|
||||
.replace(Regex("""</p\s*>""", RegexOption.IGNORE_CASE), "\n\n")
|
||||
.replace(Regex("""<[^>]+>"""), " ")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ import kotlin.math.roundToInt
|
|||
|
||||
enum class PdfAnnotationKind {
|
||||
INK,
|
||||
TEXT
|
||||
TEXT,
|
||||
HIGHLIGHT
|
||||
}
|
||||
|
||||
enum class PdfInkTool {
|
||||
|
|
@ -44,16 +45,96 @@ data class SharedPdfAnnotation(
|
|||
val tool: PdfInkTool = PdfInkTool.PEN,
|
||||
val points: List<PdfPagePoint> = emptyList(),
|
||||
val bounds: PdfPageBounds? = null,
|
||||
val boundsList: List<PdfPageBounds> = emptyList(),
|
||||
val text: String = "",
|
||||
val note: String? = null,
|
||||
val colorArgb: Int,
|
||||
val backgroundArgb: Int = 0x00FFFFFF,
|
||||
val strokeWidth: Float = 2f,
|
||||
val fontSize: Float = 16f,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
val isStrikeThrough: Boolean = false,
|
||||
val fontPath: String? = null,
|
||||
val fontName: String? = null,
|
||||
val rangeStartIndex: Int? = null,
|
||||
val rangeEndIndex: Int? = null,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfEmbeddedAnnotation(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val index: Int,
|
||||
val subtype: Int,
|
||||
val bounds: PdfPageBounds,
|
||||
val contents: String = "",
|
||||
val author: String = "",
|
||||
val name: String = "",
|
||||
val inReplyTo: String = "",
|
||||
val replies: List<SharedPdfEmbeddedAnnotation> = emptyList()
|
||||
) {
|
||||
val hasVisibleText: Boolean
|
||||
get() = contents.isNotBlank() || replies.any { it.hasVisibleText }
|
||||
}
|
||||
|
||||
object SharedPdfEmbeddedAnnotationThreads {
|
||||
fun group(
|
||||
annotations: List<SharedPdfEmbeddedAnnotation>,
|
||||
geometryTolerance: Float = 0.02f
|
||||
): List<SharedPdfEmbeddedAnnotation> {
|
||||
if (annotations.isEmpty()) return emptyList()
|
||||
|
||||
val byName = annotations
|
||||
.filter { it.name.isNotBlank() }
|
||||
.associateBy { it.name }
|
||||
val childrenByParentId = mutableMapOf<String, MutableList<SharedPdfEmbeddedAnnotation>>()
|
||||
val roots = mutableListOf<SharedPdfEmbeddedAnnotation>()
|
||||
|
||||
annotations.forEach { annotation ->
|
||||
val parent = byName[annotation.inReplyTo]
|
||||
if (parent != null && parent.id != annotation.id) {
|
||||
childrenByParentId.getOrPut(parent.id) { mutableListOf() } += annotation
|
||||
} else {
|
||||
roots += annotation
|
||||
}
|
||||
}
|
||||
|
||||
fun attachReplies(
|
||||
annotation: SharedPdfEmbeddedAnnotation,
|
||||
visitedIds: Set<String> = emptySet()
|
||||
): SharedPdfEmbeddedAnnotation {
|
||||
if (annotation.id in visitedIds) return annotation.copy(replies = emptyList())
|
||||
val nextVisited = visitedIds + annotation.id
|
||||
val replies = childrenByParentId[annotation.id]
|
||||
.orEmpty()
|
||||
.map { attachReplies(it, nextVisited) }
|
||||
return annotation.copy(replies = annotation.replies + replies)
|
||||
}
|
||||
|
||||
val groupedRoots = mutableListOf<MutableList<SharedPdfEmbeddedAnnotation>>()
|
||||
roots.map { attachReplies(it) }.forEach { annotation ->
|
||||
val group = groupedRoots.firstOrNull { existingGroup ->
|
||||
existingGroup.firstOrNull()?.bounds?.inflatedBy(geometryTolerance)?.intersects(annotation.bounds) == true
|
||||
}
|
||||
if (group == null) {
|
||||
groupedRoots += mutableListOf(annotation)
|
||||
} else {
|
||||
group += annotation
|
||||
}
|
||||
}
|
||||
|
||||
return groupedRoots
|
||||
.mapNotNull { group ->
|
||||
val root = group.firstOrNull() ?: return@mapNotNull null
|
||||
root.copy(replies = root.replies + group.drop(1))
|
||||
}
|
||||
.filter { it.hasVisibleText }
|
||||
}
|
||||
}
|
||||
|
||||
data class PdfToolConfig(
|
||||
val colorArgb: Int,
|
||||
val strokeWidth: Float
|
||||
|
|
@ -61,10 +142,10 @@ data class PdfToolConfig(
|
|||
|
||||
object SharedPdfAnnotationDefaults {
|
||||
val penPalette: List<Int> = listOf(
|
||||
0xFF111111.toInt(),
|
||||
0xFFD32F2F.toInt(),
|
||||
0xFF1976D2.toInt(),
|
||||
0xFF388E3C.toInt(),
|
||||
0xFF000000.toInt(),
|
||||
0xFFFF0000.toInt(),
|
||||
0xFF0000FF.toInt(),
|
||||
0xFF4CAF50.toInt(),
|
||||
0xFFFFFFFF.toInt()
|
||||
)
|
||||
|
||||
|
|
@ -78,13 +159,13 @@ object SharedPdfAnnotationDefaults {
|
|||
|
||||
fun configFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return when (tool) {
|
||||
PdfInkTool.PEN -> PdfToolConfig(0xFF111111.toInt(), 2.5f)
|
||||
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF111111.toInt(), 3.5f)
|
||||
PdfInkTool.PENCIL -> PdfToolConfig(0xFF616161.toInt(), 1.8f)
|
||||
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFFEB3B.toInt(), 12f)
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFF9800.toInt(), 16f)
|
||||
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 18f)
|
||||
PdfInkTool.TEXT -> PdfToolConfig(0xFF111111.toInt(), 1f)
|
||||
PdfInkTool.PEN -> PdfToolConfig(0xFFFF0000.toInt(), 0.008f)
|
||||
PdfInkTool.FOUNTAIN_PEN -> PdfToolConfig(0xFF0000FF.toInt(), 0.008f)
|
||||
PdfInkTool.PENCIL -> PdfToolConfig(0xFF444444.toInt(), 0.008f)
|
||||
PdfInkTool.HIGHLIGHTER -> PdfToolConfig(0x8CFF9800.toInt(), 0.035f)
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(0x8CFFEB3B.toInt(), 0.035f)
|
||||
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f)
|
||||
PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +197,22 @@ object SharedPdfAnnotationSerializer {
|
|||
}
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.inflatedBy(amount: Float): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = (left - amount).coerceAtLeast(0f),
|
||||
top = (top - amount).coerceAtLeast(0f),
|
||||
right = (right + amount).coerceAtMost(1f),
|
||||
bottom = (bottom + amount).coerceAtMost(1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.intersects(other: PdfPageBounds): Boolean {
|
||||
return left <= other.right &&
|
||||
right >= other.left &&
|
||||
top <= other.bottom &&
|
||||
bottom >= other.top
|
||||
}
|
||||
|
||||
data class PdfZoomSpec(
|
||||
val min: Float = 0.65f,
|
||||
val max: Float = 3.0f,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,573 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.SearchHighlightMode
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
data class SharedPdfSearchResult(
|
||||
val pageIndex: Int,
|
||||
val preview: String,
|
||||
val matchIndex: Int,
|
||||
val matchLength: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfBookmark(
|
||||
val pageIndex: Int,
|
||||
val label: String = "",
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfBookmarkStore(
|
||||
val version: Int = 1,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList()
|
||||
)
|
||||
|
||||
object SharedPdfBookmarkSerializer {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encode(bookmarks: List<SharedPdfBookmark>): String {
|
||||
return json.encodeToString(SharedPdfBookmarkStore(bookmarks = bookmarks))
|
||||
}
|
||||
|
||||
fun decode(raw: String): List<SharedPdfBookmark> {
|
||||
if (raw.isBlank()) return emptyList()
|
||||
return runCatching {
|
||||
json.decodeFromString<SharedPdfBookmarkStore>(raw).bookmarks
|
||||
}.getOrElse {
|
||||
runCatching { json.decodeFromString<List<SharedPdfBookmark>>(raw) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfJumpHistory(
|
||||
val pages: List<Int> = emptyList(),
|
||||
val cursor: Int = -1,
|
||||
val maxEntries: Int = 21
|
||||
) {
|
||||
val backPage: Int? get() = pages.getOrNull(cursor - 1)
|
||||
val forwardPage: Int? get() = pages.getOrNull(cursor + 1)
|
||||
val hasJumpTargets: Boolean get() = backPage != null || forwardPage != null
|
||||
|
||||
fun record(
|
||||
currentPageIndex: Int,
|
||||
targetPageIndex: Int,
|
||||
pageCount: Int
|
||||
): SharedPdfJumpHistory {
|
||||
if (
|
||||
pageCount <= 0 ||
|
||||
currentPageIndex !in 0 until pageCount ||
|
||||
targetPageIndex !in 0 until pageCount ||
|
||||
currentPageIndex == targetPageIndex
|
||||
) {
|
||||
return this
|
||||
}
|
||||
|
||||
val pruned = pruned(pageCount)
|
||||
val nextPages = pruned.pages.toMutableList()
|
||||
var nextCursor = pruned.cursor
|
||||
|
||||
while (nextPages.lastIndex > nextCursor) {
|
||||
nextPages.removeAt(nextPages.lastIndex)
|
||||
}
|
||||
|
||||
if (nextCursor > 0 && nextPages.getOrNull(nextCursor - 1) == currentPageIndex) {
|
||||
nextPages[nextCursor] = targetPageIndex
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
if (nextCursor == -1 || nextPages.getOrNull(nextCursor) != currentPageIndex) {
|
||||
nextPages += currentPageIndex
|
||||
nextCursor = nextPages.lastIndex
|
||||
}
|
||||
|
||||
if (nextPages.lastOrNull() != targetPageIndex) {
|
||||
nextPages += targetPageIndex
|
||||
nextCursor = nextPages.lastIndex
|
||||
}
|
||||
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun pruned(pageCount: Int): SharedPdfJumpHistory {
|
||||
if (pageCount <= 0) return clear()
|
||||
val nextPages = pages.toMutableList()
|
||||
var nextCursor = cursor
|
||||
var index = nextPages.lastIndex
|
||||
while (index >= 0) {
|
||||
if (nextPages[index] !in 0 until pageCount) {
|
||||
nextPages.removeAt(index)
|
||||
if (nextCursor >= index) nextCursor--
|
||||
}
|
||||
index--
|
||||
}
|
||||
return copy(
|
||||
pages = nextPages,
|
||||
cursor = nextCursor.coerceIn(-1, nextPages.lastIndex)
|
||||
).bounded()
|
||||
}
|
||||
|
||||
fun stepBack(): SharedPdfJumpHistory {
|
||||
return if (backPage == null) this else copy(cursor = (cursor - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun stepForward(): SharedPdfJumpHistory {
|
||||
return if (forwardPage == null) this else copy(cursor = (cursor + 1).coerceAtMost(pages.lastIndex))
|
||||
}
|
||||
|
||||
fun clear(): SharedPdfJumpHistory = copy(pages = emptyList(), cursor = -1)
|
||||
|
||||
private fun bounded(): SharedPdfJumpHistory {
|
||||
val safeMaxEntries = maxEntries.coerceAtLeast(2)
|
||||
if (pages.size <= safeMaxEntries) {
|
||||
return copy(cursor = cursor.coerceIn(-1, pages.lastIndex))
|
||||
}
|
||||
val overflow = pages.size - safeMaxEntries
|
||||
return copy(
|
||||
pages = pages.drop(overflow),
|
||||
cursor = (cursor - overflow).coerceIn(-1, pages.size - overflow - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfReaderState(
|
||||
val pageIndex: Int = 0,
|
||||
val pageCount: Int = 0,
|
||||
val displayMode: PdfDisplayMode = PdfDisplayMode.PAGINATION,
|
||||
val zoom: Float = PdfZoomSpec().default,
|
||||
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 isTextSelectionMode: Boolean = false,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList(),
|
||||
val selectedAnnotationId: String? = null,
|
||||
val annotations: List<SharedPdfAnnotation> = emptyList()
|
||||
) {
|
||||
val safePageCount: Int get() = pageCount.coerceAtLeast(0)
|
||||
val lastPageIndex: Int get() = (safePageCount - 1).coerceAtLeast(0)
|
||||
val canGoPrevious: Boolean get() = pageIndex > 0
|
||||
val canGoNext: Boolean get() = pageIndex < lastPageIndex
|
||||
val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f
|
||||
|
||||
fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState {
|
||||
val safePage = pageIndex.coerceIn(0, lastPageIndex)
|
||||
return copy(
|
||||
pageIndex = safePage,
|
||||
pageCount = safePageCount,
|
||||
activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1),
|
||||
zoom = zoomSpec.clamp(zoom),
|
||||
bookmarks = bookmarks.normalizedBookmarks(lastPageIndex),
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId ->
|
||||
annotations.any { it.id == selectedId }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun initial(
|
||||
pageCount: Int,
|
||||
initialPageIndex: Int = 0,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderState {
|
||||
val safePageCount = pageCount.coerceAtLeast(0)
|
||||
val lastPageIndex = (safePageCount - 1).coerceAtLeast(0)
|
||||
return SharedPdfReaderState(
|
||||
pageIndex = initialPageIndex.coerceIn(0, lastPageIndex),
|
||||
pageCount = safePageCount,
|
||||
zoom = zoomSpec.clamp(zoomSpec.default)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SharedPdfReaderAction {
|
||||
data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data object PreviousPage : SharedPdfReaderAction
|
||||
data object NextPage : SharedPdfReaderAction
|
||||
data object FirstPage : SharedPdfReaderAction
|
||||
data object LastPage : SharedPdfReaderAction
|
||||
data class DisplayModeChanged(val mode: PdfDisplayMode) : SharedPdfReaderAction
|
||||
data object DisplayModeToggled : SharedPdfReaderAction
|
||||
data class ZoomChanged(val zoom: Float) : SharedPdfReaderAction
|
||||
data class ZoomBy(val delta: Float) : SharedPdfReaderAction
|
||||
data class SearchChanged(val query: String) : SharedPdfReaderAction
|
||||
data class SearchHighlightModeChanged(val mode: SearchHighlightMode) : SharedPdfReaderAction
|
||||
data object SearchHighlightModeToggled : SharedPdfReaderAction
|
||||
data class GoToSearchResult(
|
||||
val resultIndex: Int,
|
||||
val results: List<SharedPdfSearchResult>
|
||||
) : SharedPdfReaderAction
|
||||
data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction
|
||||
data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction
|
||||
data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction
|
||||
data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction
|
||||
data class BookmarksLoaded(val bookmarks: List<SharedPdfBookmark>) : SharedPdfReaderAction
|
||||
data class BookmarkToggled(
|
||||
val pageIndex: Int,
|
||||
val label: String = "",
|
||||
val createdAt: Long = 0L
|
||||
) : SharedPdfReaderAction
|
||||
data class AnnotationsLoaded(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
|
||||
data class AnnotationAdded(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction
|
||||
data class AnnotationSelected(val annotationId: String?) : SharedPdfReaderAction
|
||||
data class AnnotationUpdated(val annotation: SharedPdfAnnotation) : SharedPdfReaderAction
|
||||
data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction
|
||||
data class AnnotationsChanged(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
|
||||
data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction
|
||||
}
|
||||
|
||||
fun SharedPdfReaderState.reduce(
|
||||
action: SharedPdfReaderAction,
|
||||
zoomSpec: PdfZoomSpec = PdfZoomSpec()
|
||||
): SharedPdfReaderState {
|
||||
fun goToPage(target: Int): SharedPdfReaderState {
|
||||
return copy(pageIndex = target.coerceIn(0, lastPageIndex)).coerced(zoomSpec)
|
||||
}
|
||||
|
||||
return when (action) {
|
||||
is SharedPdfReaderAction.GoToPage -> goToPage(action.pageIndex)
|
||||
SharedPdfReaderAction.PreviousPage -> goToPage(pageIndex - 1)
|
||||
SharedPdfReaderAction.NextPage -> goToPage(pageIndex + 1)
|
||||
SharedPdfReaderAction.FirstPage -> goToPage(0)
|
||||
SharedPdfReaderAction.LastPage -> goToPage(lastPageIndex)
|
||||
is SharedPdfReaderAction.DisplayModeChanged -> copy(displayMode = action.mode)
|
||||
SharedPdfReaderAction.DisplayModeToggled -> copy(
|
||||
displayMode = when (displayMode) {
|
||||
PdfDisplayMode.PAGINATION -> PdfDisplayMode.VERTICAL_SCROLL
|
||||
PdfDisplayMode.VERTICAL_SCROLL -> PdfDisplayMode.PAGINATION
|
||||
}
|
||||
)
|
||||
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,
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
is SharedPdfReaderAction.SearchHighlightModeChanged -> copy(searchHighlightMode = action.mode)
|
||||
SharedPdfReaderAction.SearchHighlightModeToggled -> copy(
|
||||
searchHighlightMode = when (searchHighlightMode) {
|
||||
SearchHighlightMode.ALL -> SearchHighlightMode.FOCUSED
|
||||
SearchHighlightMode.FOCUSED -> SearchHighlightMode.ALL
|
||||
}
|
||||
)
|
||||
is SharedPdfReaderAction.GoToSearchResult -> {
|
||||
if (action.results.isEmpty()) {
|
||||
this
|
||||
} else {
|
||||
val normalizedIndex = action.resultIndex.wrapIndex(action.results.size)
|
||||
copy(
|
||||
activeSearchResultIndex = normalizedIndex,
|
||||
pageIndex = action.results[normalizedIndex].pageIndex.coerceIn(0, lastPageIndex)
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.ToolSelected -> {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(action.tool)
|
||||
copy(
|
||||
selectedTool = action.tool,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth
|
||||
)
|
||||
}
|
||||
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.BookmarksLoaded -> copy(bookmarks = action.bookmarks.normalizedBookmarks(lastPageIndex))
|
||||
is SharedPdfReaderAction.BookmarkToggled -> {
|
||||
val page = action.pageIndex.coerceIn(0, lastPageIndex)
|
||||
val withoutPage = bookmarks.filterNot { it.pageIndex == page }
|
||||
val nextBookmarks = if (withoutPage.size == bookmarks.size) {
|
||||
withoutPage + SharedPdfBookmark(
|
||||
pageIndex = page,
|
||||
label = action.label.ifBlank { "Page ${page + 1}" },
|
||||
createdAt = action.createdAt
|
||||
)
|
||||
} else {
|
||||
withoutPage
|
||||
}
|
||||
copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex))
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList())
|
||||
is SharedPdfReaderAction.AnnotationAdded -> copy(
|
||||
annotations = annotations + action.annotation,
|
||||
selectedAnnotationId = action.annotation.id
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationSelected -> copy(
|
||||
selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } }
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationUpdated -> {
|
||||
val index = annotations.indexOfFirst { it.id == action.annotation.id }
|
||||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
copy(annotations = annotations.toMutableList().also { it[index] = action.annotation })
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationDeleted -> copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotationId },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId }
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationsChanged -> copy(annotations = action.annotations.toList())
|
||||
is SharedPdfReaderAction.UndoLastAnnotationOnPage -> {
|
||||
val index = annotations.indexOfLast { it.pageIndex == action.pageIndex }
|
||||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
val removedId = annotations[index].id
|
||||
copy(
|
||||
annotations = annotations.toMutableList().also { it.removeAt(index) },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId }
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.ClearPageAnnotations -> {
|
||||
val removedIds = annotations.filter { it.pageIndex == action.pageIndex }.map { it.id }.toSet()
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.pageIndex == action.pageIndex },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds }
|
||||
)
|
||||
}
|
||||
}.coerced(zoomSpec)
|
||||
}
|
||||
|
||||
object SharedPdfSearchEngine {
|
||||
fun search(
|
||||
pageTexts: List<String>,
|
||||
query: String,
|
||||
previewRadiusBefore: Int = 70,
|
||||
previewRadiusAfter: Int = 100
|
||||
): List<SharedPdfSearchResult> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
return pageTexts.flatMapIndexed { pageIndex, text ->
|
||||
val matches = mutableListOf<SharedPdfSearchResult>()
|
||||
var startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
val matchIndex = text.indexOf(normalized, startIndex, ignoreCase = true)
|
||||
if (matchIndex < 0) break
|
||||
matches += SharedPdfSearchResult(
|
||||
pageIndex = pageIndex,
|
||||
preview = text.previewAround(
|
||||
index = matchIndex,
|
||||
queryLength = normalized.length,
|
||||
before = previewRadiusBefore,
|
||||
after = previewRadiusAfter
|
||||
),
|
||||
matchIndex = matchIndex,
|
||||
matchLength = normalized.length
|
||||
)
|
||||
startIndex = matchIndex + normalized.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
fun highlightsForPage(
|
||||
results: List<SharedPdfSearchResult>,
|
||||
pageIndex: Int,
|
||||
activeResultIndex: Int,
|
||||
mode: SearchHighlightMode
|
||||
): List<SharedPdfSearchResult> {
|
||||
return when (mode) {
|
||||
SearchHighlightMode.ALL -> results.filter { it.pageIndex == pageIndex }
|
||||
SearchHighlightMode.FOCUSED -> {
|
||||
val active = results.getOrNull(activeResultIndex)
|
||||
if (active?.pageIndex == pageIndex) listOf(active) else emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SharedPdfSearchIndex(
|
||||
val pageCount: Int = 0
|
||||
) {
|
||||
private val pageTexts = LinkedHashMap<Int, String>()
|
||||
private val tokenPages = LinkedHashMap<String, MutableSet<Int>>()
|
||||
|
||||
val indexedPageCount: Int
|
||||
get() = pageTexts.size
|
||||
|
||||
fun hasPage(pageIndex: Int): Boolean = pageTexts.containsKey(pageIndex)
|
||||
|
||||
fun pageText(pageIndex: Int): String? = pageTexts[pageIndex]
|
||||
|
||||
fun indexedPages(): List<SharedPdfIndexedPage> {
|
||||
return pageTexts.entries
|
||||
.sortedBy { it.key }
|
||||
.map { SharedPdfIndexedPage(pageIndex = it.key, text = it.value) }
|
||||
}
|
||||
|
||||
fun putPage(pageIndex: Int, text: String) {
|
||||
if (pageCount > 0 && pageIndex !in 0 until pageCount) return
|
||||
removePageTokens(pageIndex)
|
||||
pageTexts[pageIndex] = text
|
||||
text.searchTokens().forEach { token ->
|
||||
tokenPages.getOrPut(token) { linkedSetOf() } += pageIndex
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
pageTexts.clear()
|
||||
tokenPages.clear()
|
||||
}
|
||||
|
||||
fun search(
|
||||
query: String,
|
||||
previewRadiusBefore: Int = 70,
|
||||
previewRadiusAfter: Int = 100
|
||||
): List<SharedPdfSearchResult> {
|
||||
val normalized = query.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
val matcher = SharedPdfPhraseMatcher(normalized)
|
||||
val candidates = candidatePages(matcher.tokens)
|
||||
return candidates.flatMap { pageIndex ->
|
||||
val text = pageTexts[pageIndex].orEmpty()
|
||||
matcher.findAll(text).map { match ->
|
||||
SharedPdfSearchResult(
|
||||
pageIndex = pageIndex,
|
||||
preview = text.previewAround(
|
||||
index = match.startIndex,
|
||||
queryLength = match.length,
|
||||
before = previewRadiusBefore,
|
||||
after = previewRadiusAfter
|
||||
),
|
||||
matchIndex = match.startIndex,
|
||||
matchLength = match.length
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun candidatePages(tokens: List<String>): List<Int> {
|
||||
if (tokens.isEmpty()) return pageTexts.keys.sorted()
|
||||
val candidateSets = tokens.map { token ->
|
||||
tokenPages.asSequence()
|
||||
.filter { (indexedToken, _) -> indexedToken.startsWith(token) }
|
||||
.flatMap { (_, pages) -> pages.asSequence() }
|
||||
.toSet()
|
||||
}
|
||||
if (candidateSets.any { it.isEmpty() }) return emptyList()
|
||||
return candidateSets
|
||||
.drop(1)
|
||||
.fold(candidateSets.first()) { acc, pages -> acc.intersect(pages) }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
private fun removePageTokens(pageIndex: Int) {
|
||||
if (!pageTexts.containsKey(pageIndex)) return
|
||||
val emptyTokens = mutableListOf<String>()
|
||||
tokenPages.forEach { (token, pages) ->
|
||||
pages.remove(pageIndex)
|
||||
if (pages.isEmpty()) emptyTokens += token
|
||||
}
|
||||
emptyTokens.forEach(tokenPages::remove)
|
||||
}
|
||||
}
|
||||
|
||||
data class SharedPdfIndexedPage(
|
||||
val pageIndex: Int,
|
||||
val text: String
|
||||
)
|
||||
|
||||
private data class SharedPdfPhraseMatch(
|
||||
val startIndex: Int,
|
||||
val length: Int
|
||||
)
|
||||
|
||||
private class SharedPdfPhraseMatcher(query: String) {
|
||||
val tokens: List<String> = query.searchTokens()
|
||||
private val regex = query.toSearchPhraseRegex()
|
||||
private val literal = query.takeIf { regex == null }
|
||||
|
||||
fun findAll(text: String): List<SharedPdfPhraseMatch> {
|
||||
return if (regex != null) {
|
||||
regex.findAll(text).map { match ->
|
||||
SharedPdfPhraseMatch(
|
||||
startIndex = match.range.first,
|
||||
length = match.range.last - match.range.first + 1
|
||||
)
|
||||
}.toList()
|
||||
} else {
|
||||
val needle = literal.orEmpty()
|
||||
val matches = mutableListOf<SharedPdfPhraseMatch>()
|
||||
var startIndex = 0
|
||||
while (startIndex < text.length) {
|
||||
val matchIndex = text.indexOf(needle, startIndex, ignoreCase = true)
|
||||
if (matchIndex < 0) break
|
||||
matches += SharedPdfPhraseMatch(matchIndex, needle.length)
|
||||
startIndex = matchIndex + needle.length.coerceAtLeast(1)
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toSearchPhraseRegex(): Regex? {
|
||||
val tokens = trim().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
if (tokens.size <= 1) return null
|
||||
val prefix = if (all { it.code < 128 }) "\\b" else ""
|
||||
return Regex(prefix + tokens.joinToString("\\s+") { Regex.escape(it) }, RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
private fun Int.wrapIndex(size: Int): Int {
|
||||
if (size <= 0) return -1
|
||||
return when {
|
||||
this < 0 -> size - 1
|
||||
this >= size -> 0
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SharedPdfBookmark>.normalizedBookmarks(lastPageIndex: Int): List<SharedPdfBookmark> {
|
||||
return asSequence()
|
||||
.filter { it.pageIndex in 0..lastPageIndex }
|
||||
.distinctBy { it.pageIndex }
|
||||
.sortedBy { it.pageIndex }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun String.previewAround(
|
||||
index: Int,
|
||||
queryLength: Int,
|
||||
before: Int,
|
||||
after: Int
|
||||
): String {
|
||||
val start = (index - before).coerceAtLeast(0)
|
||||
val end = (index + queryLength + after).coerceAtMost(length)
|
||||
val prefix = if (start > 0) "..." else ""
|
||||
val suffix = if (end < length) "..." else ""
|
||||
return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
|
||||
}
|
||||
|
||||
private fun String.searchTokens(): List<String> {
|
||||
val tokens = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
forEach { char ->
|
||||
if (char.isLetterOrDigit() || char == '_') {
|
||||
current.append(char.lowercaseChar())
|
||||
} else if (current.isNotEmpty()) {
|
||||
tokens += current.toString()
|
||||
current.setLength(0)
|
||||
}
|
||||
}
|
||||
if (current.isNotEmpty()) tokens += current.toString()
|
||||
return tokens.distinct()
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
data class PdfNormalizedPoint(
|
||||
val x: Float,
|
||||
val y: Float
|
||||
)
|
||||
|
||||
data class PdfTextCharBounds(
|
||||
val index: Int,
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float
|
||||
) {
|
||||
val hasBounds: Boolean
|
||||
get() = right > left && bottom > top
|
||||
}
|
||||
|
||||
object PdfSelectionGeometry {
|
||||
private const val DefaultMergedLineTolerance = 0.006f
|
||||
private const val DefaultCharLineTolerance = 0.012f
|
||||
private const val MinLineTolerance = 0.002f
|
||||
|
||||
fun normalizedPoint(
|
||||
pointX: Float,
|
||||
pointY: Float,
|
||||
viewportWidth: Int,
|
||||
viewportHeight: Int
|
||||
): PdfNormalizedPoint? {
|
||||
if (viewportWidth <= 0 || viewportHeight <= 0) return null
|
||||
return PdfNormalizedPoint(
|
||||
x = (pointX / viewportWidth).coerceIn(0f, 1f),
|
||||
y = (pointY / viewportHeight).coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
fun mergeBoundsByLine(
|
||||
bounds: List<PdfPageBounds>,
|
||||
lineTolerance: Float = DefaultMergedLineTolerance
|
||||
): List<PdfPageBounds> {
|
||||
if (bounds.isEmpty()) return emptyList()
|
||||
val lines = mutableListOf<MutableList<PdfPageBounds>>()
|
||||
bounds.sortedWith(compareBy<PdfPageBounds> { it.top }.thenBy { it.left }).forEach { boundsForChar ->
|
||||
val line = lines.firstOrNull { existing ->
|
||||
existing.any { it.isSameVisualLineAs(boundsForChar, lineTolerance) }
|
||||
}
|
||||
if (line == null) {
|
||||
lines += mutableListOf(boundsForChar)
|
||||
} else {
|
||||
line += boundsForChar
|
||||
}
|
||||
}
|
||||
return lines.map { it.toMergedBounds() }
|
||||
}
|
||||
|
||||
fun lineBoundsForChars(
|
||||
chars: List<PdfTextCharBounds>,
|
||||
lineTolerance: Float = DefaultCharLineTolerance
|
||||
): List<PdfPageBounds> {
|
||||
return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
|
||||
}
|
||||
|
||||
fun nearestCharOnLine(
|
||||
chars: List<PdfTextCharBounds>,
|
||||
point: PdfNormalizedPoint,
|
||||
lineTolerance: Float = DefaultCharLineTolerance
|
||||
): PdfTextCharBounds? {
|
||||
val lines = chars.groupByLine(lineTolerance)
|
||||
val matchingLines = lines.filter { line ->
|
||||
val top = line.minOf { it.top }
|
||||
val bottom = line.maxOf { it.bottom }
|
||||
val averageHeight = line.map { it.bottom - it.top }.average().toFloat()
|
||||
val verticalPadding = maxOf(averageHeight * 0.45f, MinLineTolerance)
|
||||
point.y in (top - verticalPadding)..(bottom + verticalPadding)
|
||||
}
|
||||
val line = matchingLines.minWithOrNull(
|
||||
compareBy<List<PdfTextCharBounds>>(
|
||||
{ lineVerticalDistance(point.y, it) },
|
||||
{ lineHorizontalDistance(point.x, it) }
|
||||
)
|
||||
) ?: return null
|
||||
|
||||
return line.minByOrNull { char ->
|
||||
horizontalDistance(point.x, char)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<PdfTextCharBounds>.groupByLine(lineTolerance: Float): List<List<PdfTextCharBounds>> {
|
||||
val lines = mutableListOf<MutableList<PdfTextCharBounds>>()
|
||||
filter { it.hasBounds }
|
||||
.sortedWith(compareBy<PdfTextCharBounds> { it.top }.thenBy { it.left })
|
||||
.forEach { char ->
|
||||
val line = lines.firstOrNull { existing ->
|
||||
val averageHeight = existing.map { it.bottom - it.top }.average().toFloat()
|
||||
val charHeight = char.bottom - char.top
|
||||
val dynamicTolerance = maxOf(minOf(averageHeight, charHeight) * 0.55f, MinLineTolerance)
|
||||
abs(existing.averageVerticalMidpoint() - char.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance)
|
||||
}
|
||||
if (line == null) {
|
||||
lines += mutableListOf(char)
|
||||
} else {
|
||||
line += char
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private fun List<PdfTextCharBounds>.toCharLineBounds(): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = minOf { it.left }.coerceIn(0f, 1f),
|
||||
top = minOf { it.top }.coerceIn(0f, 1f),
|
||||
right = maxOf { it.right }.coerceIn(0f, 1f),
|
||||
bottom = maxOf { it.bottom }.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<PdfPageBounds>.toMergedBounds(): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = minOf { it.left }.coerceIn(0f, 1f),
|
||||
top = minOf { it.top }.coerceIn(0f, 1f),
|
||||
right = maxOf { it.right }.coerceIn(0f, 1f),
|
||||
bottom = maxOf { it.bottom }.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfTextCharBounds.verticalMidpoint(): Float = (top + bottom) / 2f
|
||||
|
||||
private fun PdfPageBounds.isSameVisualLineAs(other: PdfPageBounds, lineTolerance: Float): Boolean {
|
||||
val overlap = minOf(bottom, other.bottom) - maxOf(top, other.top)
|
||||
val minHeight = minOf(bottom - top, other.bottom - other.top)
|
||||
if (overlap > 0f && overlap >= minHeight * 0.45f) return true
|
||||
|
||||
val dynamicTolerance = maxOf(minHeight * 0.35f, MinLineTolerance)
|
||||
return abs(verticalMidpoint() - other.verticalMidpoint()) <= minOf(lineTolerance, dynamicTolerance)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.verticalMidpoint(): Float = (top + bottom) / 2f
|
||||
|
||||
private fun List<PdfTextCharBounds>.averageVerticalMidpoint(): Float {
|
||||
return map { it.verticalMidpoint() }.average().toFloat()
|
||||
}
|
||||
|
||||
private fun lineVerticalDistance(pointY: Float, line: List<PdfTextCharBounds>): Float {
|
||||
val top = line.minOf { it.top }
|
||||
val bottom = line.maxOf { it.bottom }
|
||||
return when {
|
||||
pointY < top -> top - pointY
|
||||
pointY > bottom -> pointY - bottom
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun lineHorizontalDistance(pointX: Float, line: List<PdfTextCharBounds>): Float {
|
||||
val left = line.minOf { it.left }
|
||||
val right = line.maxOf { it.right }
|
||||
return when {
|
||||
pointX < left -> left - pointX
|
||||
pointX > right -> pointX - right
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun horizontalDistance(pointX: Float, char: PdfTextCharBounds): Float {
|
||||
return when {
|
||||
pointX < char.left -> char.left - pointX
|
||||
pointX > char.right -> pointX - char.right
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
data class PdfVisiblePageLayout(
|
||||
val pageIndex: Int,
|
||||
val top: Float,
|
||||
val bottom: Float
|
||||
) {
|
||||
val visibleHeight: Float
|
||||
get() = (bottom - top).coerceAtLeast(0f)
|
||||
}
|
||||
|
||||
fun mostVisiblePdfPageIndex(
|
||||
visiblePages: List<PdfVisiblePageLayout>,
|
||||
viewportTop: Float,
|
||||
viewportBottom: Float,
|
||||
fallbackPageIndex: Int
|
||||
): Int {
|
||||
return visiblePages
|
||||
.filter { it.visibleHeight > 0f }
|
||||
.map { page ->
|
||||
val top = maxOf(page.top, viewportTop)
|
||||
val bottom = minOf(page.bottom, viewportBottom)
|
||||
page to (bottom - top).coerceAtLeast(0f)
|
||||
}
|
||||
.maxByOrNull { it.second }
|
||||
?.takeIf { it.second > 0f }
|
||||
?.first
|
||||
?.pageIndex
|
||||
?: fallbackPageIndex
|
||||
}
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import com.aryan.reader.shared.localFolderSyncSha256ShortHex
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
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 kotlin.math.pow
|
||||
|
||||
object SharedPdfAnnotationSidecarCodec {
|
||||
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
|
||||
const val KEY_LEGACY_INK = "ink"
|
||||
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
|
||||
prettyPrint = true
|
||||
}
|
||||
|
||||
fun encodeAnnotationsElement(annotations: List<SharedPdfAnnotation>): JsonElement {
|
||||
return json.parseToJsonElement(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
}
|
||||
|
||||
fun decodeAnnotationsElement(element: JsonElement): List<SharedPdfAnnotation> {
|
||||
return SharedPdfAnnotationSerializer.decode(json.encodeToString(JsonElement.serializer(), element))
|
||||
}
|
||||
|
||||
fun annotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
|
||||
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) }
|
||||
|
||||
data[KEY_LEGACY_INK]?.let { ink ->
|
||||
val decoded = decodeAnnotationsElement(ink)
|
||||
if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
return legacyAndroidAnnotationsFromData(data)
|
||||
}
|
||||
|
||||
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
|
||||
if (data[KEY_PDF_ANNOTATIONS] != null) return data
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty()) return data
|
||||
return JsonObject(data + (KEY_PDF_ANNOTATIONS to encodeAnnotationsElement(annotations)))
|
||||
}
|
||||
|
||||
fun canonicalizeDataJson(rawDataJson: String): String {
|
||||
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
|
||||
return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data))
|
||||
}
|
||||
|
||||
fun legacyAndroidDataFromAnnotations(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
existingData: JsonObject = JsonObject(emptyMap())
|
||||
): JsonObject {
|
||||
if (annotations.isEmpty()) return existingData
|
||||
|
||||
val next = existingData.toMutableMap()
|
||||
if (!existingData[KEY_LEGACY_INK].isLegacyAndroidInkArray()) {
|
||||
next[KEY_LEGACY_INK] = annotations.toLegacyAndroidInkArray()
|
||||
}
|
||||
if (!existingData[KEY_LEGACY_TEXT_BOXES].isJsonArray()) {
|
||||
next[KEY_LEGACY_TEXT_BOXES] = annotations.toLegacyAndroidTextBoxArray()
|
||||
}
|
||||
if (!existingData[KEY_LEGACY_HIGHLIGHTS].isJsonArray()) {
|
||||
next[KEY_LEGACY_HIGHLIGHTS] = annotations.toLegacyAndroidHighlightArray()
|
||||
}
|
||||
return JsonObject(next)
|
||||
}
|
||||
|
||||
fun legacyAndroidDataJsonFromCanonical(rawDataJson: String): String {
|
||||
val data = parseObjectOrNull(rawDataJson) ?: return rawDataJson
|
||||
val annotations = annotationsFromData(data)
|
||||
if (annotations.isEmpty()) return rawDataJson
|
||||
return json.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
legacyAndroidDataFromAnnotations(annotations, data)
|
||||
)
|
||||
}
|
||||
|
||||
private fun legacyAndroidAnnotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
|
||||
return buildList {
|
||||
addAll(data[KEY_LEGACY_INK].parseLegacyAndroidInk())
|
||||
addAll(data[KEY_LEGACY_TEXT_BOXES].parseLegacyAndroidTextBoxes())
|
||||
addAll(data[KEY_LEGACY_HIGHLIGHTS].parseLegacyAndroidHighlights())
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidInk(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
if (!this.isLegacyAndroidInkArray()) return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val points = obj.array("points")
|
||||
?.mapNotNull { pointElement ->
|
||||
val point = pointElement.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
PdfPagePoint(
|
||||
x = point.float("x") ?: return@mapNotNull null,
|
||||
y = point.float("y") ?: return@mapNotNull null,
|
||||
timestamp = point.long("t") ?: point.long("timestamp") ?: 0L
|
||||
)
|
||||
}
|
||||
.orEmpty()
|
||||
if (points.isEmpty()) return@mapNotNull null
|
||||
|
||||
val tool = obj.string("inkType")
|
||||
?: obj.string("type")
|
||||
?: PdfInkTool.PEN.name
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("ink", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = tool.toPdfInkTool(),
|
||||
points = points,
|
||||
colorArgb = obj.int("color") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).colorArgb,
|
||||
strokeWidth = obj.float("strokeWidth") ?: SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN).strokeWidth,
|
||||
createdAt = points.firstOrNull()?.timestamp ?: 0L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidTextBoxes(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val bounds = obj.objectValue("bounds")?.toPdfPageBoundsOrNull() ?: return@mapNotNull null
|
||||
val rawFontSize = obj.float("fontSize") ?: 16f
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("text", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = bounds,
|
||||
text = obj.string("text").orEmpty(),
|
||||
colorArgb = obj.int("color") ?: 0xFF000000.toInt(),
|
||||
backgroundArgb = obj.int("backgroundColor") ?: 0x00000000,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = rawFontSize.legacyTextBoxFontSizeToShared(),
|
||||
isBold = obj.boolean("isBold") ?: false,
|
||||
isItalic = obj.boolean("isItalic") ?: false,
|
||||
isUnderline = obj.boolean("isUnderline") ?: false,
|
||||
isStrikeThrough = obj.boolean("isStrikeThrough") ?: false,
|
||||
fontPath = obj.string("fontPath"),
|
||||
fontName = obj.string("fontName")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseLegacyAndroidHighlights(): List<SharedPdfAnnotation> {
|
||||
val array = this?.jsonArrayOrNull() ?: return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val boundsList = obj.array("bounds")
|
||||
?.mapNotNull { it.jsonObjectOrNull()?.toPdfPageBoundsOrNull() }
|
||||
?.filter { it.isNormalizedPageBounds() }
|
||||
.orEmpty()
|
||||
val rangeStart = obj.int("rangeStart")
|
||||
val rangeEnd = obj.int("rangeEnd")
|
||||
if (boundsList.isEmpty() && (rangeStart == null || rangeEnd == null)) return@mapNotNull null
|
||||
val inclusiveRangeEnd = if (rangeStart != null && rangeEnd != null) {
|
||||
(rangeEnd - 1).coerceAtLeast(rangeStart)
|
||||
} else {
|
||||
rangeEnd
|
||||
}
|
||||
|
||||
val colorName = obj.string("color") ?: "YELLOW"
|
||||
SharedPdfAnnotation(
|
||||
id = obj.string("id") ?: stableAnnotationId("highlight", element),
|
||||
pageIndex = obj.int("pageIndex") ?: return@mapNotNull null,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
bounds = boundsList.firstOrNull(),
|
||||
boundsList = boundsList,
|
||||
text = obj.string("text").orEmpty(),
|
||||
note = obj.string("note"),
|
||||
colorArgb = colorName.toSharedHighlightArgb(),
|
||||
rangeStartIndex = rangeStart,
|
||||
rangeEndIndex = inclusiveRangeEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidInkArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.INK && it.points.isNotEmpty() }
|
||||
.map { annotation ->
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("annotationType", JsonPrimitive("INK"))
|
||||
put("inkType", JsonPrimitive(annotation.tool.name))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("strokeWidth", JsonPrimitive(annotation.strokeWidth.toDouble()))
|
||||
put(
|
||||
"points",
|
||||
JsonArray(
|
||||
annotation.points.map { point ->
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"x" to JsonPrimitive(point.x.toDouble()),
|
||||
"y" to JsonPrimitive(point.y.toDouble()),
|
||||
"t" to JsonPrimitive(point.timestamp)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidTextBoxArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.TEXT && it.bounds != null }
|
||||
.map { annotation ->
|
||||
val bounds = requireNotNull(annotation.bounds)
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("text", JsonPrimitive(annotation.text))
|
||||
put("color", JsonPrimitive(annotation.colorArgb))
|
||||
put("backgroundColor", JsonPrimitive(annotation.backgroundArgb))
|
||||
put("fontSize", JsonPrimitive(annotation.fontSize.sharedFontSizeToLegacyTextBox().toDouble()))
|
||||
put("isBold", JsonPrimitive(annotation.isBold))
|
||||
put("isItalic", JsonPrimitive(annotation.isItalic))
|
||||
put("isUnderline", JsonPrimitive(annotation.isUnderline))
|
||||
put("isStrikeThrough", JsonPrimitive(annotation.isStrikeThrough))
|
||||
annotation.fontPath?.let { put("fontPath", JsonPrimitive(it)) }
|
||||
annotation.fontName?.let { put("fontName", JsonPrimitive(it)) }
|
||||
put("bounds", bounds.toJsonObject())
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.toLegacyAndroidHighlightArray(): JsonArray {
|
||||
return JsonArray(
|
||||
filter { it.kind == PdfAnnotationKind.HIGHLIGHT }
|
||||
.map { annotation ->
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(annotation.id))
|
||||
put("pageIndex", JsonPrimitive(annotation.pageIndex))
|
||||
put("color", JsonPrimitive(annotation.colorArgb.toLegacyHighlightColorName()))
|
||||
put("text", JsonPrimitive(annotation.text))
|
||||
val rangeStart = annotation.rangeStartIndex ?: 0
|
||||
val rangeEnd = annotation.rangeEndIndex?.plus(1)?.coerceAtLeast(rangeStart) ?: rangeStart
|
||||
put("rangeStart", JsonPrimitive(rangeStart))
|
||||
put("rangeEnd", JsonPrimitive(rangeEnd))
|
||||
annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) }
|
||||
put("bounds", JsonArray(emptyList()))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseObjectOrNull(raw: String): JsonObject? {
|
||||
return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun stableAnnotationId(prefix: String, element: JsonElement): String {
|
||||
return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}"
|
||||
}
|
||||
|
||||
private fun JsonElement.looksLikeSharedAnnotationStore(): Boolean {
|
||||
val obj = jsonObjectOrNull()
|
||||
if (obj?.array("annotations") != null) return true
|
||||
val array = jsonArrayOrNull() ?: return false
|
||||
val first = array.firstOrNull()?.jsonObjectOrNull() ?: return false
|
||||
return first["kind"] != null && first["colorArgb"] != null
|
||||
}
|
||||
|
||||
private fun JsonElement?.isLegacyAndroidInkArray(): Boolean {
|
||||
val array = this?.jsonArrayOrNull() ?: return false
|
||||
if (array.isEmpty()) return true
|
||||
return array.all { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@all false
|
||||
obj["kind"] == null &&
|
||||
obj["points"] != null &&
|
||||
(obj["annotationType"] != null || obj["inkType"] != null || obj["type"] != null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.isJsonArray(): Boolean = this?.jsonArrayOrNull() != null
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonArray }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull()
|
||||
|
||||
private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull()
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun JsonObject.int(name: String): Int? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.long(name: String): Long? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.float(name: String): Float? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull?.toFloat() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.boolean(name: String): Boolean? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.booleanOrNull }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.toPdfPageBoundsOrNull(): PdfPageBounds? {
|
||||
val left = float("left") ?: return null
|
||||
val top = float("top") ?: return null
|
||||
val right = float("right") ?: return null
|
||||
val bottom = float("bottom") ?: return null
|
||||
return PdfPageBounds(
|
||||
left = minOf(left, right),
|
||||
top = minOf(top, bottom),
|
||||
right = maxOf(left, right),
|
||||
bottom = maxOf(top, bottom)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"left" to JsonPrimitive(left.toDouble()),
|
||||
"top" to JsonPrimitive(top.toDouble()),
|
||||
"right" to JsonPrimitive(right.toDouble()),
|
||||
"bottom" to JsonPrimitive(bottom.toDouble())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.isNormalizedPageBounds(): Boolean {
|
||||
return left in 0f..1f &&
|
||||
top in 0f..1f &&
|
||||
right in 0f..1f &&
|
||||
bottom in 0f..1f &&
|
||||
right >= left &&
|
||||
bottom >= top
|
||||
}
|
||||
|
||||
private fun String.toPdfInkTool(): PdfInkTool {
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,412 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
sealed interface SharedPdfInkRenderData {
|
||||
data class Standard(
|
||||
val path: Path,
|
||||
val color: Color,
|
||||
val strokeWidthPx: Float,
|
||||
val cap: StrokeCap,
|
||||
val blendMode: BlendMode
|
||||
) : SharedPdfInkRenderData
|
||||
|
||||
data class Fountain(
|
||||
val path: Path,
|
||||
val color: Color
|
||||
) : SharedPdfInkRenderData
|
||||
|
||||
data class Pencil(
|
||||
val path: Path,
|
||||
val color: Color,
|
||||
val strokeWidthPx: Float,
|
||||
val velocityAlpha: Float
|
||||
) : SharedPdfInkRenderData
|
||||
}
|
||||
|
||||
object SharedPdfInkRenderer {
|
||||
fun createRenderData(
|
||||
annotation: SharedPdfAnnotation,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfInkRenderData? {
|
||||
if (annotation.kind != PdfAnnotationKind.INK || annotation.points.isEmpty()) return null
|
||||
val widthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val heightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val strokeWidthPx = effectiveStrokeWidthPx(annotation.strokeWidth, widthPx)
|
||||
val color = Color(annotation.colorArgb)
|
||||
|
||||
if (annotation.points.size == 1) {
|
||||
val point = annotation.points.first()
|
||||
val x = point.x * widthPx
|
||||
val y = point.y * heightPx
|
||||
return when (annotation.tool) {
|
||||
PdfInkTool.FOUNTAIN_PEN -> {
|
||||
val path = Path().apply {
|
||||
addOval(Rect(center = Offset(x, y), radius = strokeWidthPx / 2f))
|
||||
}
|
||||
SharedPdfInkRenderData.Fountain(path = path, color = color)
|
||||
}
|
||||
PdfInkTool.PENCIL -> {
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x, y)
|
||||
}
|
||||
SharedPdfInkRenderData.Pencil(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
velocityAlpha = 1f
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x, y)
|
||||
}
|
||||
SharedPdfInkRenderData.Standard(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
cap = annotation.tool.strokeCap,
|
||||
blendMode = annotation.tool.blendMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return when (annotation.tool) {
|
||||
PdfInkTool.PENCIL -> {
|
||||
val path = annotation.points.toSmoothPath(widthPx, heightPx)
|
||||
val velocityAlpha = annotation.points.velocityAlpha(widthPx, heightPx)
|
||||
SharedPdfInkRenderData.Pencil(
|
||||
path = path,
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
velocityAlpha = velocityAlpha
|
||||
)
|
||||
}
|
||||
PdfInkTool.FOUNTAIN_PEN -> {
|
||||
val (leftSide, rightSide) = calculateFountainPenEdges(
|
||||
points = annotation.points,
|
||||
baseWidthPx = strokeWidthPx,
|
||||
pageWidthPx = widthPx,
|
||||
pageHeightPx = heightPx
|
||||
)
|
||||
val path = Path()
|
||||
if (leftSide.isNotEmpty()) {
|
||||
path.moveTo(leftSide.first().x, leftSide.first().y)
|
||||
leftSide.drop(1).forEach { path.lineTo(it.x, it.y) }
|
||||
rightSide.asReversed().forEach { path.lineTo(it.x, it.y) }
|
||||
path.close()
|
||||
}
|
||||
SharedPdfInkRenderData.Fountain(path = path, color = color)
|
||||
}
|
||||
PdfInkTool.PEN,
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND,
|
||||
PdfInkTool.ERASER,
|
||||
PdfInkTool.TEXT -> {
|
||||
SharedPdfInkRenderData.Standard(
|
||||
path = annotation.points.toSmoothPath(widthPx, heightPx),
|
||||
color = color,
|
||||
strokeWidthPx = strokeWidthPx,
|
||||
cap = annotation.tool.strokeCap,
|
||||
blendMode = annotation.tool.blendMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthPx(strokeWidth: Float, canvasSize: IntSize): Float {
|
||||
return effectiveStrokeWidthPx(strokeWidth, canvasSize.width.coerceAtLeast(1).toFloat())
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthPx(strokeWidth: Float, pageWidthPx: Float): Float {
|
||||
val safeWidth = pageWidthPx.coerceAtLeast(1f)
|
||||
return if (strokeWidth <= 1f) {
|
||||
(strokeWidth * safeWidth).coerceAtLeast(0.1f)
|
||||
} else {
|
||||
strokeWidth.coerceAtLeast(0.1f)
|
||||
}
|
||||
}
|
||||
|
||||
fun effectiveStrokeWidthNorm(strokeWidth: Float, pageWidthPx: Float): Float {
|
||||
val safeWidth = pageWidthPx.coerceAtLeast(1f)
|
||||
return if (strokeWidth <= 1f) strokeWidth.coerceAtLeast(0.0001f) else strokeWidth / safeWidth
|
||||
}
|
||||
|
||||
fun calculateSnappedPoint(
|
||||
currentPoint: PdfPagePoint,
|
||||
startPoint: PdfPagePoint?,
|
||||
pageAspectRatio: Float,
|
||||
thresholdDegrees: Double = 10.0
|
||||
): PdfPagePoint {
|
||||
if (startPoint == null) return currentPoint
|
||||
val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f
|
||||
val dx = (currentPoint.x - startPoint.x) * safeAspectRatio
|
||||
val dy = currentPoint.y - startPoint.y
|
||||
val angleDeg = atan2(dy, dx) * 180 / PI
|
||||
val absAngle = abs(angleDeg)
|
||||
val isHorizontal = absAngle < thresholdDegrees || abs(absAngle - 180.0) < thresholdDegrees
|
||||
val isVertical = abs(absAngle - 90.0) < thresholdDegrees
|
||||
return when {
|
||||
isHorizontal -> currentPoint.copy(y = startPoint.y)
|
||||
isVertical -> currentPoint.copy(x = startPoint.x)
|
||||
else -> currentPoint
|
||||
}
|
||||
}
|
||||
|
||||
fun isAnnotationHit(
|
||||
annotation: SharedPdfAnnotation,
|
||||
hitPoint: PdfPagePoint,
|
||||
pageWidthPx: Float,
|
||||
pageAspectRatio: Float,
|
||||
eraserStrokeWidth: Float = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER).strokeWidth,
|
||||
lastHitPoint: PdfPagePoint? = null
|
||||
): Boolean {
|
||||
return when (annotation.kind) {
|
||||
PdfAnnotationKind.HIGHLIGHT,
|
||||
PdfAnnotationKind.TEXT -> annotation.allBounds().any { it.contains(hitPoint.x, hitPoint.y) }
|
||||
PdfAnnotationKind.INK -> isInkAnnotationHit(
|
||||
annotation = annotation,
|
||||
hitPoint = hitPoint,
|
||||
pageWidthPx = pageWidthPx,
|
||||
pageAspectRatio = pageAspectRatio,
|
||||
eraserStrokeWidth = eraserStrokeWidth,
|
||||
lastHitPoint = lastHitPoint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInkAnnotationHit(
|
||||
annotation: SharedPdfAnnotation,
|
||||
hitPoint: PdfPagePoint,
|
||||
pageWidthPx: Float,
|
||||
pageAspectRatio: Float,
|
||||
eraserStrokeWidth: Float,
|
||||
lastHitPoint: PdfPagePoint?
|
||||
): Boolean {
|
||||
if (annotation.points.isEmpty()) return false
|
||||
val safeAspectRatio = pageAspectRatio.takeIf { it > 0f } ?: 1f
|
||||
val eraserWidthNorm = effectiveStrokeWidthNorm(eraserStrokeWidth, pageWidthPx)
|
||||
val annotationWidthNorm = effectiveStrokeWidthNorm(annotation.strokeWidth, pageWidthPx)
|
||||
val threshold = eraserWidthNorm + annotationWidthNorm / 2f
|
||||
val thresholdSq = threshold * threshold
|
||||
|
||||
fun distSqToEraser(px: Float, pyScaled: Float): Float {
|
||||
val e1x = hitPoint.x
|
||||
val e1yScaled = hitPoint.y / safeAspectRatio
|
||||
if (lastHitPoint == null) {
|
||||
val dx = px - e1x
|
||||
val dy = pyScaled - e1yScaled
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
val e0x = lastHitPoint.x
|
||||
val e0yScaled = lastHitPoint.y / safeAspectRatio
|
||||
val ex = e1x - e0x
|
||||
val ey = e1yScaled - e0yScaled
|
||||
val segmentLenSq = ex * ex + ey * ey
|
||||
if (segmentLenSq < 1e-8f) {
|
||||
val dx = px - e1x
|
||||
val dy = pyScaled - e1yScaled
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
val t = ((px - e0x) * ex + (pyScaled - e0yScaled) * ey) / segmentLenSq
|
||||
val closestX = e0x + ex * t.coerceIn(0f, 1f)
|
||||
val closestY = e0yScaled + ey * t.coerceIn(0f, 1f)
|
||||
val dx = px - closestX
|
||||
val dy = pyScaled - closestY
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
if (annotation.points.size == 1) {
|
||||
val p = annotation.points.first()
|
||||
return distSqToEraser(p.x, p.y / safeAspectRatio) < thresholdSq
|
||||
}
|
||||
|
||||
for (i in 0 until annotation.points.lastIndex) {
|
||||
val a = annotation.points[i]
|
||||
val b = annotation.points[i + 1]
|
||||
val pax = hitPoint.x - a.x
|
||||
val pay = (hitPoint.y - a.y) / safeAspectRatio
|
||||
val bax = b.x - a.x
|
||||
val bay = (b.y - a.y) / safeAspectRatio
|
||||
val segmentLenSq = (bax * bax + bay * bay).coerceAtLeast(1e-6f)
|
||||
val t = ((pax * bax + pay * bay) / segmentLenSq).coerceIn(0f, 1f)
|
||||
val closestX = bax * t
|
||||
val closestY = bay * t
|
||||
val dx = pax - closestX
|
||||
val dy = pay - closestY
|
||||
if (dx * dx + dy * dy < thresholdSq) return true
|
||||
|
||||
if (lastHitPoint != null) {
|
||||
if (distSqToEraser(a.x, a.y / safeAspectRatio) < thresholdSq) return true
|
||||
if (distSqToEraser(b.x, b.y / safeAspectRatio) < thresholdSq) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun calculateFountainPenEdges(
|
||||
points: List<PdfPagePoint>,
|
||||
baseWidthPx: Float,
|
||||
pageWidthPx: Float,
|
||||
pageHeightPx: Float
|
||||
): Pair<List<Offset>, List<Offset>> {
|
||||
if (points.size < 2) return emptyList<Offset>() to emptyList<Offset>()
|
||||
|
||||
val leftSide = mutableListOf<Offset>()
|
||||
val rightSide = mutableListOf<Offset>()
|
||||
val computedWidths = FloatArray(points.size)
|
||||
computedWidths[0] = baseWidthPx
|
||||
val velocityFactor = 300f
|
||||
|
||||
for (i in 1 until points.size) {
|
||||
val p0 = points[i - 1]
|
||||
val p1 = points[i]
|
||||
val dx = p1.x - p0.x
|
||||
val dy = p1.y - p0.y
|
||||
val aspect = if (pageWidthPx > 0f && pageHeightPx > 0f) pageHeightPx / pageWidthPx else 1f
|
||||
val scaledDy = dy * aspect
|
||||
val distNorm = sqrt(dx * dx + scaledDy * scaledDy)
|
||||
val timeDelta = (p1.timestamp - p0.timestamp).coerceAtLeast(1)
|
||||
val velocityNorm = distNorm / timeDelta
|
||||
val targetWidth = (baseWidthPx * (1f / (1f + velocityNorm * velocityFactor))).coerceIn(
|
||||
baseWidthPx * 0.2f,
|
||||
baseWidthPx * 1.4f
|
||||
)
|
||||
computedWidths[i] = computedWidths[i - 1] * 0.6f + targetWidth * 0.4f
|
||||
}
|
||||
|
||||
for (i in 0 until points.lastIndex) {
|
||||
val current = points[i]
|
||||
val next = points[i + 1]
|
||||
val currentX = current.x * pageWidthPx
|
||||
val currentY = current.y * pageHeightPx
|
||||
val nextX = next.x * pageWidthPx
|
||||
val nextY = next.y * pageHeightPx
|
||||
val angle = atan2(nextY - currentY, nextX - currentX)
|
||||
val normalAngle = angle - (PI / 2f).toFloat()
|
||||
val halfWidth = computedWidths[i] / 2f
|
||||
leftSide += Offset(
|
||||
x = currentX + cos(normalAngle) * halfWidth,
|
||||
y = currentY + sin(normalAngle) * halfWidth
|
||||
)
|
||||
rightSide += Offset(
|
||||
x = currentX - cos(normalAngle) * halfWidth,
|
||||
y = currentY - sin(normalAngle) * halfWidth
|
||||
)
|
||||
}
|
||||
|
||||
val last = points.last()
|
||||
val previous = points[points.lastIndex - 1]
|
||||
val lastX = last.x * pageWidthPx
|
||||
val lastY = last.y * pageHeightPx
|
||||
val previousX = previous.x * pageWidthPx
|
||||
val previousY = previous.y * pageHeightPx
|
||||
val lastAngle = atan2(lastY - previousY, lastX - previousX)
|
||||
val lastNormal = lastAngle - (PI / 2f).toFloat()
|
||||
val lastHalfWidth = computedWidths.last() / 2f
|
||||
leftSide += Offset(
|
||||
x = lastX + cos(lastNormal) * lastHalfWidth,
|
||||
y = lastY + sin(lastNormal) * lastHalfWidth
|
||||
)
|
||||
rightSide += Offset(
|
||||
x = lastX - cos(lastNormal) * lastHalfWidth,
|
||||
y = lastY - sin(lastNormal) * lastHalfWidth
|
||||
)
|
||||
return leftSide to rightSide
|
||||
}
|
||||
}
|
||||
|
||||
fun PdfInkTool.sharedPdfStrokeWidthRange(): ClosedFloatingPointRange<Float> {
|
||||
return when (this) {
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> 0.01f..0.06f
|
||||
PdfInkTool.ERASER -> 0.002f..0.10f
|
||||
PdfInkTool.TEXT -> 0.01f..0.08f
|
||||
PdfInkTool.PEN,
|
||||
PdfInkTool.FOUNTAIN_PEN,
|
||||
PdfInkTool.PENCIL -> 0.001f..0.015f
|
||||
}
|
||||
}
|
||||
|
||||
fun Float.sharedPdfStrokePercent(range: ClosedFloatingPointRange<Float>): Int {
|
||||
val span = (range.endInclusive - range.start).coerceAtLeast(0.0001f)
|
||||
return (((this - range.start) / span) * 100f).toInt().coerceIn(1, 100)
|
||||
}
|
||||
|
||||
private val PdfInkTool.strokeCap: StrokeCap
|
||||
get() = when (this) {
|
||||
PdfInkTool.HIGHLIGHTER -> StrokeCap.Butt
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> StrokeCap.Round
|
||||
else -> StrokeCap.Round
|
||||
}
|
||||
|
||||
private val PdfInkTool.blendMode: BlendMode
|
||||
get() = when (this) {
|
||||
PdfInkTool.HIGHLIGHTER,
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> BlendMode.Multiply
|
||||
else -> BlendMode.SrcOver
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.contains(x: Float, y: Float): Boolean {
|
||||
return x in left..right && y in top..bottom
|
||||
}
|
||||
|
||||
private fun SharedPdfAnnotation.allBounds(): List<PdfPageBounds> {
|
||||
return boundsList.ifEmpty { listOfNotNull(bounds) }
|
||||
}
|
||||
|
||||
private fun List<PdfPagePoint>.toSmoothPath(widthPx: Float, heightPx: Float): Path {
|
||||
val path = Path()
|
||||
val first = first()
|
||||
path.moveTo(first.x * widthPx, first.y * heightPx)
|
||||
for (i in 1 until size) {
|
||||
val p0 = this[i - 1]
|
||||
val p1 = this[i]
|
||||
val p0x = p0.x * widthPx
|
||||
val p0y = p0.y * heightPx
|
||||
val p1x = p1.x * widthPx
|
||||
val p1y = p1.y * heightPx
|
||||
val midX = (p0x + p1x) / 2f
|
||||
val midY = (p0y + p1y) / 2f
|
||||
if (i == 1) {
|
||||
path.lineTo(midX, midY)
|
||||
} else {
|
||||
path.quadraticTo(p0x, p0y, midX, midY)
|
||||
}
|
||||
}
|
||||
val last = last()
|
||||
path.lineTo(last.x * widthPx, last.y * heightPx)
|
||||
return path
|
||||
}
|
||||
|
||||
private fun List<PdfPagePoint>.velocityAlpha(widthPx: Float, heightPx: Float): Float {
|
||||
if (size < 2) return 1f
|
||||
var totalDistance = 0f
|
||||
for (i in 1 until size) {
|
||||
val p0 = this[i - 1]
|
||||
val p1 = this[i]
|
||||
val dx = (p1.x - p0.x) * widthPx
|
||||
val dy = (p1.y - p0.y) * heightPx
|
||||
totalDistance += sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
val duration = (last().timestamp - first().timestamp).coerceAtLeast(1)
|
||||
val velocity = totalDistance / duration
|
||||
return (1f - (velocity - 0.2f) / 1.8f).coerceIn(0.4f, 1f)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,353 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.ceil
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextStyleConfig(
|
||||
val colorArgb: Int = 0xFF000000.toInt(),
|
||||
val backgroundColorArgb: Int = 0x00000000,
|
||||
val fontSize: Float = 16f,
|
||||
val isBold: Boolean = false,
|
||||
val isItalic: Boolean = false,
|
||||
val isUnderline: Boolean = false,
|
||||
val isStrikeThrough: Boolean = false,
|
||||
val fontPath: String? = null,
|
||||
val fontName: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextFontPreset(
|
||||
val name: String,
|
||||
val fontPath: String? = null
|
||||
)
|
||||
|
||||
enum class SharedPdfTextResizeHandle {
|
||||
TOP_LEFT,
|
||||
TOP_CENTER,
|
||||
TOP_RIGHT,
|
||||
RIGHT_CENTER,
|
||||
BOTTOM_RIGHT,
|
||||
BOTTOM_CENTER,
|
||||
BOTTOM_LEFT,
|
||||
LEFT_CENTER
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfTextDraft(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val bounds: PdfPageBounds,
|
||||
val text: String = "",
|
||||
val style: SharedPdfTextStyleConfig = SharedPdfTextStyleConfig(),
|
||||
val createdAt: Long = 0L,
|
||||
val isManuallySized: Boolean = false
|
||||
)
|
||||
|
||||
object SharedPdfTextAnnotationDefaults {
|
||||
val fontSizes: List<Float> = listOf(12f, 14f, 16f, 18f, 20f, 24f, 30f)
|
||||
|
||||
val fontPresets: List<SharedPdfTextFontPreset> = listOf(
|
||||
SharedPdfTextFontPreset("Default"),
|
||||
SharedPdfTextFontPreset("Merriweather", "asset:fonts/merriweather.ttf"),
|
||||
SharedPdfTextFontPreset("Lato", "asset:fonts/lato.ttf"),
|
||||
SharedPdfTextFontPreset("Lora", "asset:fonts/lora.ttf"),
|
||||
SharedPdfTextFontPreset("Roboto Mono", "asset:fonts/roboto_mono.ttf"),
|
||||
SharedPdfTextFontPreset("Lexend", "asset:fonts/lexend.ttf")
|
||||
)
|
||||
|
||||
val textColorPalette: List<Int>
|
||||
get() = SharedPdfAnnotationDefaults.penPalette
|
||||
|
||||
val backgroundColorPalette: List<Int> = listOf(
|
||||
0x00000000,
|
||||
0x8CFF9800.toInt(),
|
||||
0x8CFFEB3B.toInt(),
|
||||
0x8C81C784.toInt(),
|
||||
0x8C64B5F6.toInt(),
|
||||
0x8CE1BEE7.toInt()
|
||||
)
|
||||
|
||||
fun normalizeTextDraft(text: String): String {
|
||||
return text
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
fun createAnnotation(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
createdAt: Long
|
||||
): SharedPdfAnnotation {
|
||||
val cleanText = normalizeTextDraft(text)
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = boundsForPlacedText(anchor, canvasSize, cleanText, style),
|
||||
text = cleanText,
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun createDraft(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
createdAt: Long
|
||||
): SharedPdfTextDraft {
|
||||
return SharedPdfTextDraft(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
bounds = boundsForPlacedText(anchor, canvasSize, " ", style),
|
||||
text = "",
|
||||
style = style,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun boundsForPlacedText(
|
||||
anchor: PdfPagePoint,
|
||||
canvasSize: IntSize,
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig
|
||||
): 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 left = anchor.x.coerceIn(0f, 1f - widthNorm)
|
||||
val top = anchor.y.coerceIn(0f, 1f - heightNorm)
|
||||
return PdfPageBounds(
|
||||
left = left,
|
||||
top = top,
|
||||
right = left + widthNorm,
|
||||
bottom = top + heightNorm
|
||||
)
|
||||
}
|
||||
|
||||
fun estimateLineCount(text: String, fontSize: Float, widthPx: Float): Int {
|
||||
if (text.isBlank()) return 1
|
||||
val averageCharWidth = (fontSize * 0.55f).coerceAtLeast(1f)
|
||||
val charsPerLine = (widthPx / averageCharWidth).toInt().coerceAtLeast(8)
|
||||
return text.lineSequence().sumOf { rawLine ->
|
||||
val length = rawLine.length.coerceAtLeast(1)
|
||||
ceil(length / charsPerLine.toFloat()).toInt().coerceAtLeast(1)
|
||||
}.coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private fun estimateWidthNorm(
|
||||
text: String,
|
||||
style: SharedPdfTextStyleConfig,
|
||||
pageWidthPx: Float
|
||||
): Float {
|
||||
val longestLine = text.lineSequence().maxOfOrNull { it.length } ?: 0
|
||||
val estimatedTextWidth = (longestLine.coerceAtLeast(12) * style.fontSize * 0.55f) + 18f
|
||||
return (estimatedTextWidth / pageWidthPx).coerceAtLeast(0.28f)
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withText(
|
||||
text: String,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfTextDraft {
|
||||
val normalizedText = text
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
if (isManuallySized) {
|
||||
return copy(text = normalizedText)
|
||||
}
|
||||
val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt)
|
||||
return copy(
|
||||
text = normalizedText,
|
||||
bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = anchor,
|
||||
canvasSize = canvasSize,
|
||||
text = normalizedText.ifBlank { " " },
|
||||
style = style
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withStyle(
|
||||
style: SharedPdfTextStyleConfig,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfTextDraft {
|
||||
if (isManuallySized) {
|
||||
return copy(style = style)
|
||||
}
|
||||
val anchor = PdfPagePoint(bounds.left, bounds.top, createdAt)
|
||||
return copy(
|
||||
style = style,
|
||||
bounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = anchor,
|
||||
canvasSize = canvasSize,
|
||||
text = text.ifBlank { " " },
|
||||
style = style
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.withBounds(bounds: PdfPageBounds): SharedPdfTextDraft {
|
||||
return copy(bounds = bounds.coercedToPage(), isManuallySized = true)
|
||||
}
|
||||
|
||||
fun SharedPdfTextDraft.toAnnotation(): SharedPdfAnnotation {
|
||||
val cleanText = SharedPdfTextAnnotationDefaults.normalizeTextDraft(text)
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = bounds,
|
||||
text = cleanText,
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
strokeWidth = SharedPdfAnnotationDefaults.configFor(PdfInkTool.TEXT).strokeWidth,
|
||||
fontSize = style.fontSize,
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun PdfPageBounds.resizedBy(
|
||||
handle: SharedPdfTextResizeHandle,
|
||||
deltaXPx: Float,
|
||||
deltaYPx: Float,
|
||||
canvasSize: IntSize,
|
||||
minWidthPx: Float = 50f,
|
||||
minHeightPx: Float = 50f
|
||||
): PdfPageBounds {
|
||||
val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val minWidth = minWidthPx.coerceIn(1f, pageWidthPx)
|
||||
val minHeight = minHeightPx.coerceIn(1f, pageHeightPx)
|
||||
|
||||
var leftPx = left * pageWidthPx
|
||||
var topPx = top * pageHeightPx
|
||||
var rightPx = right * pageWidthPx
|
||||
var bottomPx = bottom * pageHeightPx
|
||||
|
||||
when (handle) {
|
||||
SharedPdfTextResizeHandle.TOP_LEFT -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.TOP_CENTER -> {
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.TOP_RIGHT -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
topPx = (topPx + deltaYPx).coerceIn(0f, (bottomPx - minHeight).coerceAtLeast(0f))
|
||||
}
|
||||
SharedPdfTextResizeHandle.RIGHT_CENTER -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_RIGHT -> {
|
||||
rightPx = (rightPx + deltaXPx).coerceIn((leftPx + minWidth).coerceAtMost(pageWidthPx), pageWidthPx)
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_CENTER -> {
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.BOTTOM_LEFT -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
bottomPx = (bottomPx + deltaYPx).coerceIn((topPx + minHeight).coerceAtMost(pageHeightPx), pageHeightPx)
|
||||
}
|
||||
SharedPdfTextResizeHandle.LEFT_CENTER -> {
|
||||
leftPx = (leftPx + deltaXPx).coerceIn(0f, (rightPx - minWidth).coerceAtLeast(0f))
|
||||
}
|
||||
}
|
||||
|
||||
return PdfPageBounds(
|
||||
left = leftPx / pageWidthPx,
|
||||
top = topPx / pageHeightPx,
|
||||
right = rightPx / pageWidthPx,
|
||||
bottom = bottomPx / pageHeightPx
|
||||
).coercedToPage()
|
||||
}
|
||||
|
||||
fun PdfPageBounds.movedBy(
|
||||
deltaXPx: Float,
|
||||
deltaYPx: Float,
|
||||
canvasSize: IntSize
|
||||
): PdfPageBounds {
|
||||
val pageWidthPx = canvasSize.width.coerceAtLeast(1).toFloat()
|
||||
val pageHeightPx = canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
val widthPx = ((right - left) * pageWidthPx).coerceIn(1f, pageWidthPx)
|
||||
val heightPx = ((bottom - top) * pageHeightPx).coerceIn(1f, pageHeightPx)
|
||||
val nextLeftPx = ((left * pageWidthPx) + deltaXPx).coerceIn(0f, (pageWidthPx - widthPx).coerceAtLeast(0f))
|
||||
val nextTopPx = ((top * pageHeightPx) + deltaYPx).coerceIn(0f, (pageHeightPx - heightPx).coerceAtLeast(0f))
|
||||
return PdfPageBounds(
|
||||
left = nextLeftPx / pageWidthPx,
|
||||
top = nextTopPx / pageHeightPx,
|
||||
right = (nextLeftPx + widthPx) / pageWidthPx,
|
||||
bottom = (nextTopPx + heightPx) / pageHeightPx
|
||||
).coercedToPage()
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.sharedPdfTextStyle(): SharedPdfTextStyleConfig {
|
||||
return SharedPdfTextStyleConfig(
|
||||
colorArgb = colorArgb,
|
||||
backgroundColorArgb = backgroundArgb,
|
||||
fontSize = fontSize,
|
||||
isBold = isBold,
|
||||
isItalic = isItalic,
|
||||
isUnderline = isUnderline,
|
||||
isStrikeThrough = isStrikeThrough,
|
||||
fontPath = fontPath,
|
||||
fontName = fontName
|
||||
)
|
||||
}
|
||||
|
||||
fun SharedPdfAnnotation.withSharedPdfTextStyle(style: SharedPdfTextStyleConfig): SharedPdfAnnotation {
|
||||
return copy(
|
||||
colorArgb = style.colorArgb,
|
||||
backgroundArgb = style.backgroundColorArgb,
|
||||
fontSize = style.fontSize,
|
||||
isBold = style.isBold,
|
||||
isItalic = style.isItalic,
|
||||
isUnderline = style.isUnderline,
|
||||
isStrikeThrough = style.isStrikeThrough,
|
||||
fontPath = style.fontPath,
|
||||
fontName = style.fontName
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.coercedToPage(): PdfPageBounds {
|
||||
val coercedLeft = left.coerceIn(0f, 1f)
|
||||
val coercedTop = top.coerceIn(0f, 1f)
|
||||
val coercedRight = right.coerceIn(coercedLeft, 1f)
|
||||
val coercedBottom = bottom.coerceIn(coercedTop, 1f)
|
||||
return PdfPageBounds(
|
||||
left = coercedLeft,
|
||||
top = coercedTop,
|
||||
right = coercedRight,
|
||||
bottom = coercedBottom
|
||||
)
|
||||
}
|
||||
|
|
@ -1,66 +1,152 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticFlexContainer
|
||||
import com.aryan.reader.paginatedreader.SemanticList
|
||||
import com.aryan.reader.paginatedreader.SemanticTable
|
||||
import com.aryan.reader.paginatedreader.SemanticTextBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
||||
import com.aryan.reader.shared.HighlightColor
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
|
||||
sealed interface ReaderLinkTarget {
|
||||
data class External(val url: String) : ReaderLinkTarget
|
||||
data class Internal(val locator: ReaderLocator) : ReaderLinkTarget
|
||||
data object Ignored : ReaderLinkTarget
|
||||
}
|
||||
|
||||
data class ReaderBookmark(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val preview: String
|
||||
val preview: String,
|
||||
val locator: ReaderLocator = ReaderLocator(pageIndex = pageIndex, textQuote = preview)
|
||||
)
|
||||
|
||||
data class ReaderSearchResult(
|
||||
val pageIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val preview: String
|
||||
val preview: String,
|
||||
val matchIndex: Int = 0,
|
||||
val chapterIndex: Int = 0,
|
||||
val locator: ReaderLocator = ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = matchIndex,
|
||||
textQuote = preview
|
||||
)
|
||||
)
|
||||
|
||||
data class ReaderSearchOptions(
|
||||
val matchCase: Boolean = false,
|
||||
val wholeWords: Boolean = false
|
||||
)
|
||||
|
||||
data class ReaderSessionState(
|
||||
val reader: PaginatedReaderState,
|
||||
val bookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val highlights: List<UserHighlight> = emptyList(),
|
||||
val isSearchActive: Boolean = false,
|
||||
val showSearchResultsPanel: Boolean = true,
|
||||
val searchQuery: String = "",
|
||||
val searchOptions: ReaderSearchOptions = ReaderSearchOptions(),
|
||||
val searchResults: List<ReaderSearchResult> = emptyList(),
|
||||
val activeSearchResultIndex: Int = -1
|
||||
val activeSearchResultIndex: Int = -1,
|
||||
val navigationLocator: ReaderLocator? = null,
|
||||
val navigationRequestId: Long = 0L
|
||||
) {
|
||||
val currentBookmark: ReaderBookmark?
|
||||
get() = bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex }
|
||||
get() = navigationLocator
|
||||
?.let { locator -> bookmarks.firstOrNull { it.locator.sameLocation(locator) } }
|
||||
?: bookmarks.firstOrNull { it.pageIndex == reader.currentPageIndex && !it.locator.hasTextRange }
|
||||
|
||||
val activeSearchResult: ReaderSearchResult?
|
||||
get() = searchResults.getOrNull(activeSearchResultIndex)
|
||||
|
||||
val canGoToPreviousSearchResult: Boolean
|
||||
get() = when {
|
||||
activeSearchResultIndex > 0 -> true
|
||||
activeSearchResultIndex >= 0 -> false
|
||||
else -> searchResults.any { it.pageIndex <= reader.currentPageIndex }
|
||||
}
|
||||
|
||||
val canGoToNextSearchResult: Boolean
|
||||
get() = when {
|
||||
activeSearchResultIndex in 0 until searchResults.lastIndex -> true
|
||||
activeSearchResultIndex >= 0 -> false
|
||||
else -> searchResults.any { it.pageIndex >= reader.currentPageIndex }
|
||||
}
|
||||
}
|
||||
|
||||
class ReaderEngine(
|
||||
private val paginator: SimplePaginator = SimplePaginator()
|
||||
) {
|
||||
private data class PaginationCacheKey(
|
||||
val bookId: String,
|
||||
val chapterSignature: Int,
|
||||
val settings: ReaderSettings
|
||||
)
|
||||
|
||||
private val paginationCache = object : LinkedHashMap<PaginationCacheKey, List<ReaderPage>>(8, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<PaginationCacheKey, List<ReaderPage>>?): Boolean {
|
||||
return size > 8
|
||||
}
|
||||
}
|
||||
|
||||
fun createSession(
|
||||
book: SharedEpubBook,
|
||||
settings: ReaderSettings = ReaderSettings()
|
||||
settings: ReaderSettings = ReaderSettings(),
|
||||
initialPageIndex: Int = 0,
|
||||
bookmarks: List<ReaderBookmark> = emptyList(),
|
||||
highlights: List<UserHighlight> = emptyList()
|
||||
): ReaderSessionState {
|
||||
val pages = pagesFor(book, settings)
|
||||
val initialIndex = initialPageIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0))
|
||||
val reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = pages,
|
||||
currentPageIndex = initialIndex,
|
||||
settings = settings
|
||||
)
|
||||
return ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = paginator.paginate(book, settings),
|
||||
settings = settings
|
||||
)
|
||||
reader = reader,
|
||||
bookmarks = bookmarks
|
||||
.mapNotNull { it.normalizedForBook(book, pages) }
|
||||
.distinctBy { it.locationKey() }
|
||||
.sortedWith(compareBy<ReaderBookmark> { it.pageIndex }.thenBy { it.locator.startOffset ?: -1 }),
|
||||
highlights = highlights
|
||||
.map { it.withNormalizedLocator() }
|
||||
.filter { (it.locator.chapterIndex ?: it.chapterIndex) in book.chapters.indices }
|
||||
.distinctBy { it.id },
|
||||
navigationLocator = reader.currentPage?.toLocator(book)
|
||||
)
|
||||
}
|
||||
|
||||
fun next(state: ReaderSessionState): ReaderSessionState {
|
||||
if (!state.reader.canGoNext) return state
|
||||
return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex + 1))
|
||||
return goToPage(state, state.reader.currentPageIndex + 1)
|
||||
}
|
||||
|
||||
fun previous(state: ReaderSessionState): ReaderSessionState {
|
||||
if (!state.reader.canGoPrevious) return state
|
||||
return state.copy(reader = state.reader.copy(currentPageIndex = state.reader.currentPageIndex - 1))
|
||||
return goToPage(state, state.reader.currentPageIndex - 1)
|
||||
}
|
||||
|
||||
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
|
||||
val target = pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val page = state.reader.pages.getOrNull(target)
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target }
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
|
||||
navigationLocator = page?.toLocator(state.reader.book),
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
|
||||
fun goToPageNumber(state: ReaderSessionState, pageNumber: Int): ReaderSessionState {
|
||||
return goToPage(state, pageNumber - 1)
|
||||
}
|
||||
|
||||
fun goToProgress(state: ReaderSessionState, progress: Float): ReaderSessionState {
|
||||
if (state.reader.pages.isEmpty()) return state
|
||||
val target = ((state.reader.pages.lastIndex) * progress.coerceIn(0f, 1f)).toInt()
|
||||
|
|
@ -72,24 +158,307 @@ class ReaderEngine(
|
|||
return if (pageIndex >= 0) goToPage(state, pageIndex) else state
|
||||
}
|
||||
|
||||
fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
|
||||
val pageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: return state
|
||||
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()
|
||||
)
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = pageIndex),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
|
||||
navigationLocator = normalizedLocator,
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveLink(
|
||||
state: ReaderSessionState,
|
||||
href: String,
|
||||
sourceChapterIndex: Int? = state.reader.currentPage?.chapterIndex
|
||||
): ReaderLinkTarget {
|
||||
val trimmed = href.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
logReaderLink("resolve_ignored reason=blank")
|
||||
return ReaderLinkTarget.Ignored
|
||||
}
|
||||
val normalizedHref = when {
|
||||
trimmed.startsWith("about:blank#", ignoreCase = true) -> "#${trimmed.substringAfter('#')}"
|
||||
trimmed.startsWith("www.", ignoreCase = true) -> "https://$trimmed"
|
||||
else -> trimmed
|
||||
}
|
||||
logReaderLink("resolve_start href=\"$trimmed\" normalized=\"$normalizedHref\" sourceChapter=$sourceChapterIndex")
|
||||
|
||||
val scheme = normalizedHref.schemeOrNull()
|
||||
if (scheme != null) {
|
||||
return when (scheme.lowercase()) {
|
||||
"http", "https", "mailto", "tel" -> {
|
||||
logReaderLink("resolve_external scheme=$scheme")
|
||||
ReaderLinkTarget.External(normalizedHref)
|
||||
}
|
||||
else -> {
|
||||
logReaderLink("resolve_ignored reason=unsupported_scheme scheme=$scheme")
|
||||
ReaderLinkTarget.Ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sourceIndex = sourceChapterIndex
|
||||
?.takeIf { it in state.reader.book.chapters.indices }
|
||||
?: state.reader.currentPage?.chapterIndex
|
||||
?: 0
|
||||
val sourceChapter = state.reader.book.chapters.getOrNull(sourceIndex)
|
||||
?: run {
|
||||
logReaderLink("resolve_ignored reason=missing_source sourceChapter=$sourceIndex")
|
||||
return ReaderLinkTarget.Ignored
|
||||
}
|
||||
|
||||
val pathPart = normalizedHref.substringBefore('#').substringBefore('?')
|
||||
val fragment = normalizedHref.substringAfter('#', missingDelimiterValue = "").substringBefore('?')
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.percentDecodedOrSelf()
|
||||
|
||||
val targetChapterIndex = if (pathPart.isBlank()) {
|
||||
sourceIndex
|
||||
} else {
|
||||
val targetPath = resolveEpubPath(sourceChapter.baseHref, pathPart.percentDecodedOrSelf())
|
||||
state.reader.book.chapters.indexOfFirst { chapter ->
|
||||
val chapterPath = normalizeEpubPath(chapter.baseHref.orEmpty())
|
||||
chapterPath == targetPath ||
|
||||
chapter.id == pathPart ||
|
||||
chapterPath.substringAfterLast('/') == targetPath.substringAfterLast('/')
|
||||
}
|
||||
}
|
||||
|
||||
if (targetChapterIndex !in state.reader.book.chapters.indices) {
|
||||
logReaderLink(
|
||||
"resolve_ignored reason=missing_target path=\"$pathPart\" sourceChapter=$sourceIndex " +
|
||||
"base=\"${sourceChapter.baseHref.orEmpty()}\""
|
||||
)
|
||||
return ReaderLinkTarget.Ignored
|
||||
}
|
||||
|
||||
val targetChapter = state.reader.book.chapters[targetChapterIndex]
|
||||
val targetOffset = fragment
|
||||
?.let { targetChapter.semanticBlocks.findElementOffset(it) }
|
||||
?: 0
|
||||
val targetPageIndex = state.reader.pages.indexOfFirst { page ->
|
||||
page.chapterIndex == targetChapterIndex && targetOffset in page.startOffset..page.endOffset
|
||||
}.takeIf { it >= 0 }
|
||||
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = targetChapterIndex,
|
||||
chapterId = targetChapter.id,
|
||||
href = targetChapter.baseHref,
|
||||
pageIndex = targetPageIndex,
|
||||
startOffset = targetOffset,
|
||||
endOffset = targetOffset,
|
||||
cfi = "desktop:$targetChapterIndex:$targetOffset:$targetOffset"
|
||||
)
|
||||
logReaderLink(
|
||||
"resolve_internal targetChapter=$targetChapterIndex targetPage=$targetPageIndex " +
|
||||
"fragment=\"${fragment.orEmpty()}\" offset=$targetOffset"
|
||||
)
|
||||
return ReaderLinkTarget.Internal(locator)
|
||||
}
|
||||
|
||||
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)
|
||||
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 },
|
||||
navigationLocator = normalizedLocator ?: state.navigationLocator
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
|
||||
return state.copy(reader = paginator.repaginate(state.reader, settings))
|
||||
val current = state.reader.currentPage
|
||||
val pages = pagesFor(state.reader.book, settings)
|
||||
val newIndex = if (current == null) {
|
||||
0
|
||||
} else {
|
||||
pages.indexOfFirst {
|
||||
it.chapterIndex == current.chapterIndex && it.startOffset <= current.startOffset && it.endOffset >= current.startOffset
|
||||
}.takeIf { it >= 0 } ?: 0
|
||||
}
|
||||
val updated = state.copy(
|
||||
reader = state.reader.copy(
|
||||
pages = pages,
|
||||
currentPageIndex = newIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)),
|
||||
settings = settings
|
||||
)
|
||||
)
|
||||
return if (updated.searchQuery.isNotBlank()) search(updated, updated.searchQuery) 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
|
||||
)
|
||||
return synchronized(paginationCache) {
|
||||
paginationCache.getOrPut(key) {
|
||||
paginator.paginate(book, settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openSearch(state: ReaderSessionState): ReaderSessionState {
|
||||
return state.copy(isSearchActive = true, showSearchResultsPanel = true)
|
||||
}
|
||||
|
||||
fun closeSearch(state: ReaderSessionState): ReaderSessionState {
|
||||
return state.copy(
|
||||
isSearchActive = false,
|
||||
showSearchResultsPanel = true,
|
||||
searchQuery = "",
|
||||
searchResults = emptyList(),
|
||||
activeSearchResultIndex = -1
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleSearchResultsPanel(state: ReaderSessionState): ReaderSessionState {
|
||||
return state.copy(showSearchResultsPanel = !state.showSearchResultsPanel)
|
||||
}
|
||||
|
||||
fun updateSearchOptions(state: ReaderSessionState, options: ReaderSearchOptions): ReaderSessionState {
|
||||
val updated = state.copy(searchOptions = options)
|
||||
return if (updated.searchQuery.isBlank()) updated else search(updated, updated.searchQuery)
|
||||
}
|
||||
|
||||
fun toggleBookmark(state: ReaderSessionState): ReaderSessionState {
|
||||
val page = state.reader.currentPage ?: return state
|
||||
val existing = state.bookmarks.firstOrNull { it.pageIndex == state.reader.currentPageIndex }
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val locator = state.navigationLocator
|
||||
?.takeIf { it.belongsTo(page) }
|
||||
?.normalizedForPage(state, page.pageIndex)
|
||||
?: ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = page.text.preview()
|
||||
)
|
||||
val preview = locator.textQuote?.takeIf { it.isNotBlank() } ?: page.text.preview()
|
||||
return toggleBookmarkAtLocator(
|
||||
state = state,
|
||||
locator = locator,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = preview
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleBookmarkAtLocator(
|
||||
state: ReaderSessionState,
|
||||
locator: ReaderLocator,
|
||||
chapterTitle: String? = null,
|
||||
preview: String? = null
|
||||
): ReaderSessionState {
|
||||
val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: state.reader.currentPageIndex
|
||||
val page = state.reader.pages.getOrNull(targetPageIndex) ?: return state
|
||||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = targetPageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = preview ?: page.text.preview(),
|
||||
cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
|
||||
)
|
||||
val existing = state.bookmarks.firstOrNull {
|
||||
it.locator.sameLocation(normalizedLocator) ||
|
||||
(!normalizedLocator.hasTextRange && it.pageIndex == targetPageIndex)
|
||||
}
|
||||
val updated = if (existing != null) {
|
||||
state.bookmarks - existing
|
||||
} else {
|
||||
state.bookmarks + ReaderBookmark(
|
||||
id = "${state.reader.book.id}_${state.reader.currentPageIndex}",
|
||||
pageIndex = state.reader.currentPageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.preview()
|
||||
id = bookmarkId(state.reader.book.id, targetPageIndex, normalizedLocator),
|
||||
pageIndex = targetPageIndex,
|
||||
chapterTitle = chapterTitle ?: page.chapterTitle,
|
||||
preview = preview ?: page.text.preview(),
|
||||
locator = normalizedLocator
|
||||
)
|
||||
}
|
||||
return state.copy(bookmarks = updated.sortedBy { it.pageIndex })
|
||||
return state.copy(
|
||||
bookmarks = updated.sortedWith(
|
||||
compareBy<ReaderBookmark> { it.pageIndex }.thenBy { it.locator.startOffset ?: -1 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun upsertHighlight(state: ReaderSessionState, highlight: UserHighlight): ReaderSessionState {
|
||||
if (highlight.text.isBlank()) return state
|
||||
val normalized = highlight.withNormalizedLocator()
|
||||
val existingIndex = state.highlights.indexOfFirst {
|
||||
it.id == normalized.id ||
|
||||
(it.chapterIndex == normalized.chapterIndex && it.locator.sameLocation(normalized.locator))
|
||||
}
|
||||
val updated = state.highlights.toMutableList()
|
||||
if (existingIndex >= 0) {
|
||||
updated[existingIndex] = updated[existingIndex].copy(
|
||||
cfi = normalized.cfi,
|
||||
text = normalized.text,
|
||||
color = normalized.color,
|
||||
chapterIndex = normalized.chapterIndex,
|
||||
locator = normalized.locator
|
||||
)
|
||||
} else {
|
||||
updated += normalized
|
||||
}
|
||||
return state.copy(
|
||||
highlights = updated
|
||||
.filter { (it.locator.chapterIndex ?: it.chapterIndex) in state.reader.book.chapters.indices }
|
||||
.distinctBy { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
fun updateHighlight(
|
||||
state: ReaderSessionState,
|
||||
highlightId: String,
|
||||
color: HighlightColor? = null,
|
||||
note: String? = null
|
||||
): ReaderSessionState {
|
||||
return state.copy(
|
||||
highlights = state.highlights.map { highlight ->
|
||||
if (highlight.id == highlightId) {
|
||||
highlight.copy(
|
||||
color = color ?: highlight.color,
|
||||
note = if (note != null) note.takeIf { it.isNotBlank() } else highlight.note
|
||||
)
|
||||
} else {
|
||||
highlight
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteHighlight(state: ReaderSessionState, highlightId: String): ReaderSessionState {
|
||||
return state.copy(highlights = state.highlights.filterNot { it.id == highlightId })
|
||||
}
|
||||
|
||||
fun search(state: ReaderSessionState, query: String): ReaderSessionState {
|
||||
|
|
@ -97,57 +466,305 @@ class ReaderEngine(
|
|||
val results = if (normalized.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
state.reader.pages.mapNotNull { page ->
|
||||
val index = page.text.indexOf(normalized, ignoreCase = true)
|
||||
if (index < 0) {
|
||||
null
|
||||
} else {
|
||||
ReaderSearchResult(
|
||||
pageIndex = page.pageIndex,
|
||||
chapterTitle = page.chapterTitle,
|
||||
preview = page.text.previewAround(index, normalized.length)
|
||||
)
|
||||
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(
|
||||
isSearchActive = state.isSearchActive || normalized.isNotBlank(),
|
||||
showSearchResultsPanel = state.showSearchResultsPanel || normalized.isNotBlank(),
|
||||
searchQuery = query,
|
||||
searchResults = results,
|
||||
activeSearchResultIndex = activeIndex
|
||||
)
|
||||
return updated.activeSearchResult?.let { goToPage(updated, it.pageIndex) } ?: updated
|
||||
return updated.activeSearchResult?.let { goToSearchResult(updated, activeIndex) } ?: updated
|
||||
}
|
||||
|
||||
fun nextSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
if (state.searchResults.isEmpty()) return state
|
||||
val nextIndex = if (state.activeSearchResultIndex < state.searchResults.lastIndex) {
|
||||
val targetIndex = if (state.activeSearchResultIndex >= 0) {
|
||||
state.activeSearchResultIndex + 1
|
||||
} else {
|
||||
0
|
||||
state.searchResults.indexOfFirst { it.pageIndex >= state.reader.currentPageIndex }
|
||||
}
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = state.searchResults[nextIndex].pageIndex),
|
||||
activeSearchResultIndex = nextIndex
|
||||
)
|
||||
if (targetIndex !in state.searchResults.indices) return state
|
||||
return goToSearchResult(state, targetIndex)
|
||||
}
|
||||
|
||||
fun previousSearchResult(state: ReaderSessionState): ReaderSessionState {
|
||||
if (state.searchResults.isEmpty()) return state
|
||||
val nextIndex = if (state.activeSearchResultIndex > 0) {
|
||||
val targetIndex = if (state.activeSearchResultIndex >= 0) {
|
||||
state.activeSearchResultIndex - 1
|
||||
} else {
|
||||
state.searchResults.lastIndex
|
||||
state.searchResults.indexOfLast { it.pageIndex <= state.reader.currentPageIndex }
|
||||
}
|
||||
if (targetIndex !in state.searchResults.indices) return state
|
||||
return goToSearchResult(state, targetIndex)
|
||||
}
|
||||
|
||||
fun goToSearchResult(state: ReaderSessionState, resultIndex: Int): ReaderSessionState {
|
||||
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) }
|
||||
.takeIf { it >= 0 }
|
||||
?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
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 = state.searchResults[nextIndex].pageIndex),
|
||||
activeSearchResultIndex = nextIndex
|
||||
reader = state.reader.copy(currentPageIndex = targetPage),
|
||||
activeSearchResultIndex = targetIndex,
|
||||
navigationLocator = result.locator.copy(pageIndex = targetPage).withFallbacks(
|
||||
chapterIndex = page?.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = targetPage
|
||||
),
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
||||
val targetChapter = locator.chapterIndex
|
||||
if (targetChapter != null && targetChapter != chapterIndex) return false
|
||||
if (locator.hasTextRange) {
|
||||
val start = locator.startOffset ?: return false
|
||||
val end = locator.endOffset ?: start
|
||||
return if (start == end) {
|
||||
start in startOffset..endOffset
|
||||
} else {
|
||||
start < endOffset && end > startOffset
|
||||
}
|
||||
}
|
||||
val targetPage = locator.pageIndex
|
||||
return targetPage != null && targetPage == pageIndex
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<ReaderPage>): ReaderBookmark? {
|
||||
val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: pageIndex.takeIf { it in pages.indices }
|
||||
?: return null
|
||||
val page = pages.getOrNull(targetPageIndex) ?: return null
|
||||
val chapter = book.chapters.getOrNull(page.chapterIndex)
|
||||
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = targetPageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
textQuote = preview.ifBlank { page.text.preview() },
|
||||
cfi = locator.cfi ?: page.toDesktopCfi()
|
||||
)
|
||||
return copy(
|
||||
pageIndex = targetPageIndex,
|
||||
chapterTitle = chapterTitle.ifBlank { page.chapterTitle },
|
||||
preview = preview.ifBlank { normalizedLocator.textQuote ?: page.text.preview() },
|
||||
locator = normalizedLocator
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.locationKey(): String {
|
||||
val locator = locator
|
||||
return listOf(
|
||||
locator.chapterIndex,
|
||||
locator.pageIndex,
|
||||
locator.startOffset,
|
||||
locator.endOffset,
|
||||
locator.cfi
|
||||
).joinToString(":")
|
||||
}
|
||||
|
||||
private fun bookmarkId(bookId: String, pageIndex: Int, locator: ReaderLocator): String {
|
||||
val chapter = locator.chapterIndex ?: -1
|
||||
val start = locator.startOffset ?: -1
|
||||
val end = locator.endOffset ?: start
|
||||
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}"
|
||||
}
|
||||
|
||||
private fun ReaderLocator.belongsTo(page: ReaderPage): Boolean {
|
||||
val targetChapter = chapterIndex
|
||||
if (targetChapter != null && targetChapter != page.chapterIndex) return false
|
||||
if (pageIndex == page.pageIndex) return true
|
||||
val start = startOffset
|
||||
val end = endOffset ?: start
|
||||
if (start != null && end != null) {
|
||||
return if (start == end) {
|
||||
start in page.startOffset..page.endOffset
|
||||
} else {
|
||||
start < page.endOffset && end > page.startOffset
|
||||
}
|
||||
}
|
||||
return pageIndex == page.pageIndex
|
||||
}
|
||||
|
||||
private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex: Int): ReaderLocator? {
|
||||
val page = state.reader.pages.getOrNull(pageIndex) ?: return null
|
||||
val chapter = state.reader.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 ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
|
||||
val chapter = book.chapters.getOrNull(chapterIndex)
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = startOffset,
|
||||
endOffset = endOffset,
|
||||
textQuote = text.preview(),
|
||||
cfi = toDesktopCfi()
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderPage.toDesktopCfi(): String {
|
||||
return "desktop:$chapterIndex:$startOffset:$endOffset"
|
||||
}
|
||||
|
||||
private fun String.schemeOrNull(): String? {
|
||||
val colonIndex = indexOf(':')
|
||||
if (colonIndex <= 0) return null
|
||||
val firstPathIndex = listOf(indexOf('/'), indexOf('?'), indexOf('#'))
|
||||
.filter { it >= 0 }
|
||||
.minOrNull()
|
||||
if (firstPathIndex != null && firstPathIndex < colonIndex) return null
|
||||
val candidate = substring(0, colonIndex)
|
||||
return candidate.takeIf { it.all { char -> char.isLetterOrDigit() || char == '+' || char == '-' || char == '.' } }
|
||||
}
|
||||
|
||||
private fun resolveEpubPath(baseHref: String?, hrefPath: String): String {
|
||||
val path = hrefPath.trimStart('/')
|
||||
if (path.isBlank()) return normalizeEpubPath(baseHref.orEmpty())
|
||||
val base = baseHref.orEmpty()
|
||||
val baseDirectory = if (base.substringAfterLast('/', base).contains('.')) {
|
||||
base.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
return normalizeEpubPath(if (baseDirectory.isBlank()) path else "$baseDirectory/$path")
|
||||
}
|
||||
|
||||
private fun normalizeEpubPath(path: String): String {
|
||||
val parts = mutableListOf<String>()
|
||||
path.replace('\\', '/')
|
||||
.split('/')
|
||||
.forEach { part ->
|
||||
when (part) {
|
||||
"", "." -> Unit
|
||||
".." -> if (parts.isNotEmpty()) parts.removeAt(parts.lastIndex)
|
||||
else -> parts += part
|
||||
}
|
||||
}
|
||||
return parts.joinToString("/")
|
||||
}
|
||||
|
||||
private fun String.percentDecodedOrSelf(): String {
|
||||
return runCatching {
|
||||
val output = StringBuilder()
|
||||
val bytes = mutableListOf<Byte>()
|
||||
fun flushBytes() {
|
||||
if (bytes.isNotEmpty()) {
|
||||
output.append(bytes.toByteArray().decodeToString())
|
||||
bytes.clear()
|
||||
}
|
||||
}
|
||||
var index = 0
|
||||
while (index < length) {
|
||||
val char = this[index]
|
||||
if (char == '%' && index + 2 < length) {
|
||||
val value = substring(index + 1, index + 3).toIntOrNull(16)
|
||||
if (value != null) {
|
||||
bytes += value.toByte()
|
||||
index += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
flushBytes()
|
||||
output.append(char)
|
||||
index++
|
||||
}
|
||||
flushBytes()
|
||||
output.toString()
|
||||
}.getOrDefault(this)
|
||||
}
|
||||
|
||||
private fun Iterable<SemanticBlock>.findElementOffset(elementId: String): Int? {
|
||||
for (block in this) {
|
||||
block.findElementOffset(elementId)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun SemanticBlock.findElementOffset(elementId: String): Int? {
|
||||
if (this is SemanticTextBlock) {
|
||||
if (this.elementId == elementId) return startCharOffsetInSource
|
||||
spans.firstOrNull { it.elementId == elementId }?.let { span ->
|
||||
return startCharOffsetInSource + span.start.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
return when (this) {
|
||||
is SemanticList -> items.findElementOffset(elementId)
|
||||
is SemanticTable -> rows.asSequence()
|
||||
.flatMap { it.asSequence() }
|
||||
.mapNotNull { it.content.findElementOffset(elementId) }
|
||||
.firstOrNull()
|
||||
is SemanticFlexContainer -> children.findElementOffset(elementId)
|
||||
is SemanticWrappingBlock -> paragraphsToWrap.findElementOffset(elementId)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserHighlight.withNormalizedLocator(): UserHighlight {
|
||||
val normalizedLocator = locator.copy(textQuote = text).withFallbacks(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
return copy(
|
||||
chapterIndex = normalizedLocator.chapterIndex ?: chapterIndex,
|
||||
cfi = normalizedLocator.cfi ?: cfi,
|
||||
locator = normalizedLocator
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.preview(): String {
|
||||
return trim()
|
||||
.replace(Regex("\\s+"), " ")
|
||||
|
|
@ -161,3 +778,23 @@ private fun String.previewAround(index: Int, queryLength: Int): String {
|
|||
val suffix = if (end < length) "..." else ""
|
||||
return prefix + substring(start, end).replace(Regex("\\s+"), " ").trim() + suffix
|
||||
}
|
||||
|
||||
private fun String.indexOfSearch(query: String, startIndex: Int, options: ReaderSearchOptions): Int {
|
||||
var index = indexOf(query, startIndex, ignoreCase = !options.matchCase)
|
||||
if (!options.wholeWords) return index
|
||||
while (index >= 0) {
|
||||
val before = getOrNull(index - 1)
|
||||
val after = getOrNull(index + query.length)
|
||||
if (!before.isWordChar() && !after.isWordChar()) return index
|
||||
index = indexOf(query, index + query.length.coerceAtLeast(1), ignoreCase = !options.matchCase)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun Char?.isWordChar(): Boolean {
|
||||
return this != null && (isLetterOrDigit() || this == '_')
|
||||
}
|
||||
|
||||
private fun logReaderLink(message: String) {
|
||||
println("ReaderLinkResolve $message")
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,9 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.shared.PageInfoMode
|
||||
import com.aryan.reader.shared.PageInfoPosition
|
||||
import com.aryan.reader.shared.SystemUiMode
|
||||
|
||||
data class SharedEpubBook(
|
||||
val id: String,
|
||||
|
|
@ -20,10 +23,7 @@ data class SharedEpubChapter(
|
|||
val baseHref: String? = null
|
||||
)
|
||||
|
||||
data class ReaderLocator(
|
||||
val chapterIndex: Int = 0,
|
||||
val charOffset: Int = 0
|
||||
)
|
||||
typealias ReaderLocator = com.aryan.reader.shared.ReaderLocator
|
||||
|
||||
enum class ReaderReadingMode {
|
||||
PAGINATED,
|
||||
|
|
@ -44,8 +44,26 @@ data class ReaderSettings(
|
|||
val readingMode: ReaderReadingMode = ReaderReadingMode.PAGINATED,
|
||||
val textAlign: SharedReaderTextAlign = SharedReaderTextAlign.START,
|
||||
val pageWidth: Int = 760,
|
||||
val fontFamily: String = "Default"
|
||||
)
|
||||
val fontFamily: String = "Default",
|
||||
val paragraphSpacing: Float = 1.0f,
|
||||
val imageScale: Float = 1.0f,
|
||||
val horizontalMargin: Int? = null,
|
||||
val verticalMargin: Int? = null,
|
||||
val themeId: String? = null,
|
||||
val textureId: String? = null,
|
||||
val textureAlpha: Float = 0.55f,
|
||||
val customFontPath: String? = null,
|
||||
val backgroundColorArgb: Long? = null,
|
||||
val textColorArgb: Long? = null,
|
||||
val systemUiMode: SystemUiMode = SystemUiMode.DEFAULT,
|
||||
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
|
||||
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
|
||||
val seamlessChapterNavigation: Boolean = true,
|
||||
val chapterTurnDragMultiplier: Float = 1.0f
|
||||
) {
|
||||
val resolvedHorizontalMargin: Int get() = horizontalMargin ?: margin
|
||||
val resolvedVerticalMargin: Int get() = verticalMargin ?: margin
|
||||
}
|
||||
|
||||
data class ReaderPage(
|
||||
val pageIndex: Int,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package com.aryan.reader.shared.reader
|
||||
|
||||
object SharedTextBookFactory {
|
||||
fun fromPlainText(
|
||||
id: String,
|
||||
fileName: String,
|
||||
title: String,
|
||||
plainText: String,
|
||||
author: String? = null
|
||||
): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
title = title,
|
||||
author = author,
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = title,
|
||||
plainText = plainText.ifBlank { "This document did not contain readable text." }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun fromHtml(
|
||||
id: String,
|
||||
fileName: String,
|
||||
title: String,
|
||||
html: String,
|
||||
author: String? = null
|
||||
): SharedEpubBook {
|
||||
val sanitizedHtml = html.sanitizeReaderHtml()
|
||||
val body = sanitizedHtml.extractBodyOrSelf()
|
||||
return SharedEpubBook(
|
||||
id = id,
|
||||
fileName = fileName,
|
||||
title = title,
|
||||
author = author,
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = sanitizedHtml.tagText("h1")
|
||||
.ifBlank { sanitizedHtml.tagText("title") }
|
||||
.ifBlank { title },
|
||||
plainText = sanitizedHtml.htmlToText().ifBlank { title },
|
||||
htmlContent = body
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.extractBodyOrSelf(): String {
|
||||
return Regex("(?is)<body\\b[^>]*>(.*?)</body>")
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.trim()
|
||||
?: this
|
||||
}
|
||||
|
||||
private fun String.tagText(tag: String): String {
|
||||
return Regex("<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>", RegexOption.IGNORE_CASE)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.htmlToText()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.htmlToText(): String {
|
||||
return replace(Regex("(?is)<script.*?</script>"), "")
|
||||
.replace(Regex("(?is)<style.*?</style>"), "")
|
||||
.replace(Regex("(?i)<br\\s*/?>"), "\n")
|
||||
.replace(Regex("(?i)</p\\s*>"), "\n\n")
|
||||
.replace(Regex("(?i)</h[1-6]\\s*>"), "\n\n")
|
||||
.replace(Regex("<[^>]+>"), " ")
|
||||
.decodeEntities()
|
||||
.replace(Regex("[ \\t\\x0B\\f\\r]+"), " ")
|
||||
.replace(Regex(" *\\n *"), "\n")
|
||||
.replace(Regex("\\n{3,}"), "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun String.decodeEntities(): String {
|
||||
return replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(Regex("&#x([0-9a-fA-F]+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull(16)?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
.replace(Regex("&#(\\d+);")) { match ->
|
||||
match.groupValues[1].toIntOrNull()?.toChar()?.toString().orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sanitizeReaderHtml(): String {
|
||||
return replace(Regex("(?is)<script\\b.*?</script>"), "")
|
||||
.replace(Regex("(?is)<object\\b.*?</object>"), "")
|
||||
.replace(Regex("(?is)<embed\\b[^>]*>"), "")
|
||||
.replace(Regex("""(?i)\s+on[a-z]+\s*=\s*(['"]).*?\1"""), "")
|
||||
}
|
||||
}
|
||||
|
|
@ -98,8 +98,8 @@ class SimplePaginator {
|
|||
viewportWidth: Int,
|
||||
viewportHeight: Int
|
||||
): Int {
|
||||
val usableWidth = (viewportWidth - settings.margin * 2).coerceAtLeast(360)
|
||||
val usableHeight = (viewportHeight - settings.margin * 2).coerceAtLeast(360)
|
||||
val usableWidth = (viewportWidth - settings.resolvedHorizontalMargin * 2).coerceAtLeast(360)
|
||||
val usableHeight = (viewportHeight - settings.resolvedVerticalMargin * 2).coerceAtLeast(360)
|
||||
val averageCharWidth = settings.fontSize * 0.55f
|
||||
val lineHeight = settings.fontSize * settings.lineSpacing
|
||||
val charsPerLine = (usableWidth / averageCharWidth).toInt().coerceAtLeast(35)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
internal expect fun LocalBookCoverImage(
|
||||
path: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
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.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.ShelfType
|
||||
import com.aryan.reader.shared.isOpdsStream
|
||||
import com.aryan.reader.shared.progressPercentValue
|
||||
import com.aryan.reader.shared.toHomeScreenModel
|
||||
|
||||
enum class SharedAppToolAction {
|
||||
IMPORT_FILES,
|
||||
IMPORT_FOLDER,
|
||||
SYNC,
|
||||
APP_THEME,
|
||||
AI_SETTINGS,
|
||||
CUSTOM_FONTS,
|
||||
HELP_FEEDBACK,
|
||||
SUPPORT,
|
||||
ABOUT,
|
||||
TABS_TOGGLE
|
||||
}
|
||||
|
||||
data class SharedAppShellModel(
|
||||
val primaryTabs: List<SharedAppTab>,
|
||||
val selectedPrimaryTab: SharedAppTab,
|
||||
val toolActions: List<SharedAppToolAction>
|
||||
)
|
||||
|
||||
fun sharedAppShellModel(
|
||||
selectedTab: SharedAppTab,
|
||||
aiSettingsAvailable: Boolean
|
||||
): SharedAppShellModel {
|
||||
val primaryTabs = listOf(
|
||||
SharedAppTab.HOME,
|
||||
SharedAppTab.LIBRARY,
|
||||
SharedAppTab.CATALOGS,
|
||||
SharedAppTab.READER
|
||||
)
|
||||
val selectedPrimaryTab = when (selectedTab) {
|
||||
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.CUSTOM_FONTS,
|
||||
SharedAppTab.SUPPORT,
|
||||
SharedAppTab.FEEDBACK,
|
||||
SharedAppTab.ABOUT -> SharedAppTab.HOME
|
||||
else -> selectedTab
|
||||
}
|
||||
val toolActions = buildList {
|
||||
add(SharedAppToolAction.IMPORT_FILES)
|
||||
add(SharedAppToolAction.IMPORT_FOLDER)
|
||||
add(SharedAppToolAction.SYNC)
|
||||
add(SharedAppToolAction.APP_THEME)
|
||||
if (aiSettingsAvailable) add(SharedAppToolAction.AI_SETTINGS)
|
||||
add(SharedAppToolAction.CUSTOM_FONTS)
|
||||
add(SharedAppToolAction.HELP_FEEDBACK)
|
||||
add(SharedAppToolAction.SUPPORT)
|
||||
add(SharedAppToolAction.ABOUT)
|
||||
add(SharedAppToolAction.TABS_TOGGLE)
|
||||
}
|
||||
return SharedAppShellModel(
|
||||
primaryTabs = primaryTabs,
|
||||
selectedPrimaryTab = selectedPrimaryTab,
|
||||
toolActions = toolActions
|
||||
)
|
||||
}
|
||||
|
||||
data class NonReaderHomeLayoutModel(
|
||||
val continueBook: BookItem?,
|
||||
val activeTabs: List<BookItem>,
|
||||
val pinnedBooks: List<BookItem>,
|
||||
val recentBooks: List<BookItem>,
|
||||
val selectedBooks: List<BookItem>,
|
||||
val isContextualModeActive: Boolean,
|
||||
val isEmpty: Boolean,
|
||||
val isLibraryEmpty: Boolean
|
||||
)
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderHomeLayoutModel(): NonReaderHomeLayoutModel {
|
||||
val model = toHomeScreenModel()
|
||||
val activeTabs = if (isTabsEnabled) model.openTabs else emptyList()
|
||||
val continueBook = activeTabs.firstOrNull { it.id == activeTabBookId }
|
||||
?: model.recentBooks.firstOrNull { progressPercentValue(it.progressPercentage) in 1..99 }
|
||||
?: model.recentBooks.firstOrNull()
|
||||
val continueId = continueBook?.id
|
||||
val pinnedBooks = model.recentBooks
|
||||
.filter { it.id in pinnedHomeBookIds && it.id != continueId }
|
||||
val recentBooks = model.recentBooks
|
||||
.filter { it.id !in pinnedHomeBookIds && it.id != continueId }
|
||||
return NonReaderHomeLayoutModel(
|
||||
continueBook = continueBook,
|
||||
activeTabs = activeTabs,
|
||||
pinnedBooks = pinnedBooks,
|
||||
recentBooks = recentBooks,
|
||||
selectedBooks = model.selectedBooks,
|
||||
isContextualModeActive = model.isContextualModeActive,
|
||||
isEmpty = continueBook == null && pinnedBooks.isEmpty() && recentBooks.isEmpty() && activeTabs.isEmpty(),
|
||||
isLibraryEmpty = model.isLibraryEmpty
|
||||
)
|
||||
}
|
||||
|
||||
data class NonReaderLibraryOrganizationModel(
|
||||
val allBooksCount: Int,
|
||||
val shelfCount: Int,
|
||||
val smartShelfCount: Int,
|
||||
val tagCount: Int,
|
||||
val folderCount: Int,
|
||||
val unreadCount: Int,
|
||||
val inProgressCount: Int,
|
||||
val completedCount: Int,
|
||||
val activeFilterCount: Int,
|
||||
val availableFileTypes: List<FileType>,
|
||||
val hasInAppBooks: Boolean,
|
||||
val hasOpdsStreams: Boolean
|
||||
)
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
|
||||
val books = rawLibraryBooks
|
||||
val rootFolderCount = shelves.count { it.type == ShelfType.FOLDER && it.parentShelfId == null }
|
||||
val tagIds = (allTags.map { it.id } + books.flatMap { book -> book.tags.map { it.id } }).toSet()
|
||||
return NonReaderLibraryOrganizationModel(
|
||||
allBooksCount = books.size,
|
||||
shelfCount = shelves.count { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART },
|
||||
smartShelfCount = shelves.count { it.type == ShelfType.SMART },
|
||||
tagCount = tagIds.size,
|
||||
folderCount = maxOf(rootFolderCount, syncedFolders.size),
|
||||
unreadCount = books.count { progressPercentValue(it.progressPercentage) == 0 },
|
||||
inProgressCount = books.count { progressPercentValue(it.progressPercentage) in 1..99 },
|
||||
completedCount = books.count { progressPercentValue(it.progressPercentage) >= 100 },
|
||||
activeFilterCount = libraryFilters.activeFilterCount(),
|
||||
availableFileTypes = books
|
||||
.map { it.type }
|
||||
.filterNot { it == FileType.UNKNOWN }
|
||||
.distinct()
|
||||
.sortedBy { it.ordinal },
|
||||
hasInAppBooks = books.any { it.sourceFolder == null && !it.isOpdsStream() },
|
||||
hasOpdsStreams = books.any { it.isOpdsStream() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun LibraryFilters.activeFilterCount(): Int {
|
||||
return fileTypes.size +
|
||||
sourceFolders.size +
|
||||
tagIds.size +
|
||||
if (readStatus == ReadStatusFilter.ALL) 0 else 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,235 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderExtrasState
|
||||
import com.aryan.reader.shared.ReaderTool
|
||||
import com.aryan.reader.shared.ReaderToolbarPreferences
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.SharedPdfReaderState
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
|
||||
enum class ReaderWorkspaceKind {
|
||||
EPUB,
|
||||
PDF
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceLeftSection(val title: String) {
|
||||
CONTENTS("Contents"),
|
||||
SEARCH("Search"),
|
||||
BOOKMARKS("Bookmarks"),
|
||||
NOTES("Notes")
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceInspectorSection(val title: String) {
|
||||
APPEARANCE("Appearance"),
|
||||
TOOLS("Tools"),
|
||||
AI_TTS("AI/TTS"),
|
||||
TOOLBAR("Toolbar")
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceTopAction {
|
||||
CONTENTS,
|
||||
SEARCH,
|
||||
BOOKMARK,
|
||||
APPEARANCE,
|
||||
READ_ALOUD,
|
||||
AI,
|
||||
AUTO_SCROLL,
|
||||
TOOLS
|
||||
}
|
||||
|
||||
enum class ReaderWorkspaceBottomAction {
|
||||
PAGE_SLIDER,
|
||||
PREVIOUS,
|
||||
NEXT
|
||||
}
|
||||
|
||||
data class ReaderWorkspaceChromeModel(
|
||||
val preferAutoHide: Boolean,
|
||||
val forceVisible: Boolean,
|
||||
val forceVisibleReasons: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
data class ReaderWorkspaceModel(
|
||||
val kind: ReaderWorkspaceKind,
|
||||
val leftSections: List<ReaderWorkspaceLeftSection>,
|
||||
val inspectorSections: List<ReaderWorkspaceInspectorSection>,
|
||||
val topActions: List<ReaderWorkspaceTopAction>,
|
||||
val bottomActions: List<ReaderWorkspaceBottomAction>,
|
||||
val defaultPdfInteractionMode: PdfInkTool? = null,
|
||||
val chrome: ReaderWorkspaceChromeModel
|
||||
)
|
||||
|
||||
fun epubReaderWorkspaceModel(
|
||||
session: ReaderSessionState,
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean
|
||||
): 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 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)) {
|
||||
add(ReaderWorkspaceInspectorSection.TOOLS)
|
||||
}
|
||||
if (
|
||||
preferences.isVisible(ReaderTool.DICTIONARY) ||
|
||||
preferences.isVisible(ReaderTool.AI_FEATURES) ||
|
||||
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)
|
||||
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (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)
|
||||
}.distinct()
|
||||
val bottomActions = buildList {
|
||||
if (preferences.isVisible(ReaderTool.SLIDER)) add(ReaderWorkspaceBottomAction.PAGE_SLIDER)
|
||||
add(ReaderWorkspaceBottomAction.PREVIOUS)
|
||||
add(ReaderWorkspaceBottomAction.NEXT)
|
||||
}
|
||||
return ReaderWorkspaceModel(
|
||||
kind = ReaderWorkspaceKind.EPUB,
|
||||
leftSections = leftSections,
|
||||
inspectorSections = inspectorSections,
|
||||
topActions = topActions,
|
||||
bottomActions = bottomActions,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = session.isSearchActive,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = false,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
autoScroll = extrasState.autoScroll,
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences: ReaderToolbarPreferences,
|
||||
bottom: Boolean,
|
||||
aiAvailable: Boolean
|
||||
): List<ReaderTool> {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
return preferences.orderedVisibleTools()
|
||||
.filter { tool ->
|
||||
tool.supportsDesktopQuickAction &&
|
||||
preferences.isBottom(tool) == bottom &&
|
||||
(tool != ReaderTool.AI_FEATURES || aiAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
fun pdfReaderWorkspaceModel(
|
||||
state: SharedPdfReaderState,
|
||||
displayMode: PdfDisplayMode,
|
||||
hasContents: Boolean,
|
||||
hasBookmarks: Boolean,
|
||||
hasAnnotations: Boolean,
|
||||
hasEmbeddedComments: Boolean,
|
||||
searchActive: Boolean,
|
||||
annotationEditing: Boolean,
|
||||
richTextEditing: Boolean,
|
||||
loading: Boolean,
|
||||
errorMessage: String?,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean
|
||||
): ReaderWorkspaceModel {
|
||||
val leftSections = buildList {
|
||||
add(ReaderWorkspaceLeftSection.CONTENTS)
|
||||
add(ReaderWorkspaceLeftSection.SEARCH)
|
||||
if (hasBookmarks) add(ReaderWorkspaceLeftSection.BOOKMARKS)
|
||||
if (hasContents || hasAnnotations || hasEmbeddedComments) add(ReaderWorkspaceLeftSection.NOTES)
|
||||
}.distinct()
|
||||
val inspectorSections = listOf(
|
||||
ReaderWorkspaceInspectorSection.APPEARANCE,
|
||||
ReaderWorkspaceInspectorSection.TOOLS,
|
||||
ReaderWorkspaceInspectorSection.AI_TTS,
|
||||
ReaderWorkspaceInspectorSection.TOOLBAR
|
||||
)
|
||||
val topActions = buildList {
|
||||
add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
add(ReaderWorkspaceTopAction.SEARCH)
|
||||
add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
|
||||
add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}
|
||||
return ReaderWorkspaceModel(
|
||||
kind = ReaderWorkspaceKind.PDF,
|
||||
leftSections = leftSections,
|
||||
inspectorSections = inspectorSections,
|
||||
topActions = topActions,
|
||||
bottomActions = listOf(
|
||||
ReaderWorkspaceBottomAction.PAGE_SLIDER,
|
||||
ReaderWorkspaceBottomAction.PREVIOUS,
|
||||
ReaderWorkspaceBottomAction.NEXT
|
||||
),
|
||||
defaultPdfInteractionMode = null,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = searchActive || state.searchQuery.isNotBlank(),
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = annotationEditing || state.selectedAnnotationId != null || state.selectedTool != PdfInkTool.PEN,
|
||||
richTextEditing = richTextEditing,
|
||||
loading = loading,
|
||||
errorMessage = errorMessage,
|
||||
autoScroll = extrasState.autoScroll,
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun readerWorkspaceChromeModel(
|
||||
preferAutoHide: Boolean,
|
||||
searchActive: Boolean,
|
||||
leftPanelOpen: Boolean,
|
||||
inspectorOpen: Boolean,
|
||||
annotationEditing: Boolean,
|
||||
richTextEditing: Boolean,
|
||||
loading: Boolean,
|
||||
errorMessage: String?,
|
||||
autoScroll: ReaderAutoScrollState,
|
||||
ttsBusy: Boolean
|
||||
): ReaderWorkspaceChromeModel {
|
||||
val reasons = buildSet {
|
||||
if (searchActive) add("search")
|
||||
if (leftPanelOpen) add("left-panel")
|
||||
if (inspectorOpen) add("inspector")
|
||||
if (annotationEditing) add("annotation")
|
||||
if (richTextEditing) add("rich-text")
|
||||
if (loading) add("loading")
|
||||
if (!errorMessage.isNullOrBlank()) add("error")
|
||||
if (autoScroll.sanitized().enabled) add("auto-scroll")
|
||||
if (ttsBusy) add("tts")
|
||||
}
|
||||
return ReaderWorkspaceChromeModel(
|
||||
preferAutoHide = preferAutoHide,
|
||||
forceVisible = reasons.isNotEmpty(),
|
||||
forceVisibleReasons = reasons
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
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.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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun ReaderWorkspaceShell(
|
||||
model: ReaderWorkspaceModel,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
progressLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
topActions: @Composable RowScope.() -> Unit = {},
|
||||
leftSidebar: @Composable () -> Unit,
|
||||
rightInspector: @Composable () -> Unit,
|
||||
bottomBar: @Composable () -> Unit,
|
||||
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
|
||||
|
||||
LaunchedEffect(forceChrome, model.chrome.preferAutoHide, model.chrome.forceVisibleReasons) {
|
||||
chromeVisible = true
|
||||
if (model.chrome.preferAutoHide && !forceChrome) {
|
||||
delay(3_200)
|
||||
chromeVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
val wide = maxWidth >= 1120.dp
|
||||
val showChrome = chromeVisible || forceChrome || !model.chrome.preferAutoHide
|
||||
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()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
if (wide && rightPanelOpen && model.inspectorSections.isNotEmpty()) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showChrome) {
|
||||
bottomBar()
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(20.dp)
|
||||
.clickable { chromeVisible = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderWorkspaceTopChrome(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
progressLabel: String,
|
||||
wide: Boolean,
|
||||
leftPanelOpen: Boolean,
|
||||
rightPanelOpen: Boolean,
|
||||
onToggleLeftPanel: () -> Unit,
|
||||
onToggleRightPanel: () -> Unit,
|
||||
topActions: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
IconButton(onClick = onToggleLeftPanel) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReaderWorkspaceOverlayPanel(
|
||||
title: String,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxHeight().padding(vertical = 8.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,499 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.layout.widthIn
|
||||
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.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.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
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.Settings
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.AppContrastOption
|
||||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.CustomAppTheme
|
||||
|
||||
enum class SharedAppTab {
|
||||
HOME,
|
||||
LIBRARY,
|
||||
SHELVES,
|
||||
CATALOGS,
|
||||
READER,
|
||||
CUSTOM_FONTS,
|
||||
SUPPORT,
|
||||
FEEDBACK,
|
||||
ABOUT
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAppShell(
|
||||
selectedTab: SharedAppTab,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
||||
appTextDimFactorLight: Float = 1.0f,
|
||||
appTextDimFactorDark: Float = 1.0f,
|
||||
appSeedColor: Color? = null,
|
||||
customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
isTabsEnabled: Boolean = false,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit = {},
|
||||
onSyncRequested: () -> Unit,
|
||||
onAppThemeModeChange: (AppThemeMode) -> Unit = {},
|
||||
onAppContrastOptionChange: (AppContrastOption) -> Unit = {},
|
||||
onAppTextDimFactorLightChange: (Float) -> Unit = {},
|
||||
onAppTextDimFactorDarkChange: (Float) -> Unit = {},
|
||||
onAppSeedColorChange: (Color?) -> Unit = {},
|
||||
onCustomAppThemeAdded: (CustomAppTheme) -> Unit = {},
|
||||
onCustomAppThemeDeleted: (String) -> Unit = {},
|
||||
onTabsEnabledChange: (Boolean) -> Unit = {},
|
||||
onAiSettingsRequested: (() -> Unit)? = null,
|
||||
content: @Composable (SharedAppTab) -> Unit
|
||||
) {
|
||||
val shellModel = remember(selectedTab, onAiSettingsRequested != null) {
|
||||
sharedAppShellModel(
|
||||
selectedTab = selectedTab,
|
||||
aiSettingsAvailable = onAiSettingsRequested != null
|
||||
)
|
||||
}
|
||||
var showToolsPanel by remember { mutableStateOf(false) }
|
||||
var showAppThemeSettings by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(padding)
|
||||
) {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
content(selectedTab)
|
||||
}
|
||||
}
|
||||
|
||||
if (showToolsPanel) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f))
|
||||
.clickable { showToolsPanel = false }
|
||||
)
|
||||
SharedToolsPanel(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.widthIn(max = 390.dp),
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
aiSettingsAvailable = onAiSettingsRequested != null,
|
||||
onClose = { showToolsPanel = false },
|
||||
onImportFiles = {
|
||||
showToolsPanel = false
|
||||
onImportFiles()
|
||||
},
|
||||
onImportFolder = {
|
||||
showToolsPanel = false
|
||||
onImportFolder()
|
||||
},
|
||||
onSyncRequested = {
|
||||
showToolsPanel = false
|
||||
onSyncRequested()
|
||||
},
|
||||
onAppThemeRequested = {
|
||||
showToolsPanel = false
|
||||
showAppThemeSettings = true
|
||||
},
|
||||
onAiSettingsRequested = {
|
||||
showToolsPanel = false
|
||||
onAiSettingsRequested?.invoke()
|
||||
},
|
||||
onOpenTab = { tab ->
|
||||
showToolsPanel = false
|
||||
onTabSelected(tab)
|
||||
},
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAppThemeSettings) {
|
||||
SharedAppThemeSettingsDialog(
|
||||
appThemeMode = appThemeMode,
|
||||
appContrastOption = appContrastOption,
|
||||
appTextDimFactorLight = appTextDimFactorLight,
|
||||
appTextDimFactorDark = appTextDimFactorDark,
|
||||
appSeedColor = appSeedColor,
|
||||
customAppThemes = customAppThemes,
|
||||
onThemeModeChanged = onAppThemeModeChange,
|
||||
onContrastOptionChanged = onAppContrastOptionChange,
|
||||
onTextDimFactorLightChanged = onAppTextDimFactorLightChange,
|
||||
onTextDimFactorDarkChanged = onAppTextDimFactorDarkChange,
|
||||
onSeedColorChanged = onAppSeedColorChange,
|
||||
onCustomThemeAdded = onCustomAppThemeAdded,
|
||||
onCustomThemeDeleted = onCustomAppThemeDeleted,
|
||||
onDismiss = { showAppThemeSettings = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppSidebar(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(244.dp)
|
||||
.fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
|
||||
Text("Episteme", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Desktop library", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
primaryTabs.forEach { tab ->
|
||||
SharedSidebarNavItem(
|
||||
tab = tab,
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
HorizontalDivider()
|
||||
SharedSidebarButton(
|
||||
label = "Tools",
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onToolsClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppCompactRail(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
) {
|
||||
NavigationRail(containerColor = MaterialTheme.colorScheme.surface) {
|
||||
primaryTabs.forEach { tab ->
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) },
|
||||
icon = { Icon(tab.icon, contentDescription = null) },
|
||||
label = { Text(tab.label) }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onToolsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Tools")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarNavItem(
|
||||
tab: SharedAppTab,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
val contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(tab.icon, contentDescription = null, modifier = Modifier.size(21.dp))
|
||||
Text(tab.label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(21.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolsPanel(
|
||||
modifier: Modifier,
|
||||
isTabsEnabled: Boolean,
|
||||
aiSettingsAvailable: Boolean,
|
||||
onClose: () -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncRequested: () -> Unit,
|
||||
onAppThemeRequested: () -> Unit,
|
||||
onAiSettingsRequested: () -> Unit,
|
||||
onOpenTab: (SharedAppTab) -> Unit,
|
||||
onTabsEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Tools", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text("Import, sync, and app settings", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close tools")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Settings") {
|
||||
if (aiSettingsAvailable) {
|
||||
SharedToolRow(Icons.Default.Settings, "AI keys and models", onAiSettingsRequested)
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolsSection(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(title, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SharedAppTab.label: String
|
||||
get() = when (this) {
|
||||
SharedAppTab.HOME -> "Home"
|
||||
SharedAppTab.LIBRARY -> "Library"
|
||||
SharedAppTab.SHELVES -> "Shelves"
|
||||
SharedAppTab.CATALOGS -> "OPDS"
|
||||
SharedAppTab.READER -> "Reader"
|
||||
SharedAppTab.CUSTOM_FONTS -> "Custom fonts"
|
||||
SharedAppTab.SUPPORT -> "Support"
|
||||
SharedAppTab.FEEDBACK -> "Feedback"
|
||||
SharedAppTab.ABOUT -> "About"
|
||||
}
|
||||
|
||||
private val SharedAppTab.icon: ImageVector
|
||||
get() = when (this) {
|
||||
SharedAppTab.HOME -> Icons.Default.Home
|
||||
SharedAppTab.LIBRARY -> Icons.AutoMirrored.Filled.LibraryBooks
|
||||
SharedAppTab.SHELVES -> Icons.Default.Folder
|
||||
SharedAppTab.CATALOGS -> Icons.Default.Cloud
|
||||
SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook
|
||||
SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedAppTab.SUPPORT -> Icons.Default.Favorite
|
||||
SharedAppTab.FEEDBACK -> Icons.Default.Feedback
|
||||
SharedAppTab.ABOUT -> Icons.Default.Info
|
||||
}
|
||||
|
|
@ -0,0 +1,980 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ColorScheme
|
||||
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.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.shared.AppContrastOption
|
||||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.CustomAppTheme
|
||||
import com.materialkolor.PaletteStyle
|
||||
import com.materialkolor.dynamicColorScheme
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
private val SharedLightColorScheme = lightColorScheme(
|
||||
primary = Color(0xFF4C662B),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFCDEDA3),
|
||||
onPrimaryContainer = Color(0xFF354E16),
|
||||
secondary = Color(0xFF586249),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFDCE7C8),
|
||||
onSecondaryContainer = Color(0xFF404A33),
|
||||
tertiary = Color(0xFF386663),
|
||||
onTertiary = Color(0xFFFFFFFF),
|
||||
tertiaryContainer = Color(0xFFBCECE7),
|
||||
onTertiaryContainer = Color(0xFF1F4E4B),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF93000A),
|
||||
background = Color(0xFFF9FAEF),
|
||||
onBackground = Color(0xFF1A1C16),
|
||||
surface = Color(0xFFF9FAEF),
|
||||
onSurface = Color(0xFF1A1C16),
|
||||
surfaceVariant = Color(0xFFE1E4D5),
|
||||
onSurfaceVariant = Color(0xFF44483D),
|
||||
outline = Color(0xFF75796C),
|
||||
outlineVariant = Color(0xFFC5C8BA),
|
||||
scrim = Color(0xFF000000),
|
||||
inverseSurface = Color(0xFF2F312A),
|
||||
inverseOnSurface = Color(0xFFF1F2E6),
|
||||
inversePrimary = Color(0xFFB1D18A),
|
||||
surfaceDim = Color(0xFFDADBD0),
|
||||
surfaceBright = Color(0xFFF9FAEF),
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF),
|
||||
surfaceContainerLow = Color(0xFFF3F4E9),
|
||||
surfaceContainer = Color(0xFFEEEFE3),
|
||||
surfaceContainerHigh = Color(0xFFE8E9DE),
|
||||
surfaceContainerHighest = Color(0xFFE2E3D8)
|
||||
)
|
||||
|
||||
private val SharedDarkColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFB1D18A),
|
||||
onPrimary = Color(0xFF1F3701),
|
||||
primaryContainer = Color(0xFF354E16),
|
||||
onPrimaryContainer = Color(0xFFCDEDA3),
|
||||
secondary = Color(0xFFBFCBAD),
|
||||
onSecondary = Color(0xFF2A331E),
|
||||
secondaryContainer = Color(0xFF404A33),
|
||||
onSecondaryContainer = Color(0xFFDCE7C8),
|
||||
tertiary = Color(0xFFA0D0CB),
|
||||
onTertiary = Color(0xFF003735),
|
||||
tertiaryContainer = Color(0xFF1F4E4B),
|
||||
onTertiaryContainer = Color(0xFFBCECE7),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
background = Color(0xFF12140E),
|
||||
onBackground = Color(0xFFE2E3D8),
|
||||
surface = Color(0xFF12140E),
|
||||
onSurface = Color(0xFFE2E3D8),
|
||||
surfaceVariant = Color(0xFF44483D),
|
||||
onSurfaceVariant = Color(0xFFC5C8BA),
|
||||
outline = Color(0xFF8F9285),
|
||||
outlineVariant = Color(0xFF44483D),
|
||||
scrim = Color(0xFF000000),
|
||||
inverseSurface = Color(0xFFE2E3D8),
|
||||
inverseOnSurface = Color(0xFF2F312A),
|
||||
inversePrimary = Color(0xFF4C662B),
|
||||
surfaceDim = Color(0xFF12140E),
|
||||
surfaceBright = Color(0xFF383A32),
|
||||
surfaceContainerLowest = Color(0xFF0C0F09),
|
||||
surfaceContainerLow = Color(0xFF1A1C16),
|
||||
surfaceContainer = Color(0xFF1E201A),
|
||||
surfaceContainerHigh = Color(0xFF282B24),
|
||||
surfaceContainerHighest = Color(0xFF33362E)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedAppTheme(
|
||||
appThemeMode: AppThemeMode,
|
||||
appContrastOption: AppContrastOption,
|
||||
appTextDimFactorLight: Float,
|
||||
appTextDimFactorDark: Float,
|
||||
appSeedColor: Color?,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = resolveSharedAppDarkTheme(appThemeMode, isSystemInDarkTheme())
|
||||
val textDimFactor = sharedAppTextDimFactor(darkTheme, appTextDimFactorLight, appTextDimFactorDark)
|
||||
val colorScheme = remember(darkTheme, appContrastOption, textDimFactor, appSeedColor) {
|
||||
sharedAppColorScheme(
|
||||
darkTheme = darkTheme,
|
||||
seedColor = appSeedColor,
|
||||
contrastLevel = appContrastOption.value,
|
||||
textDimFactor = textDimFactor
|
||||
)
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveSharedAppDarkTheme(mode: AppThemeMode, isSystemDark: Boolean): Boolean {
|
||||
return when (mode) {
|
||||
AppThemeMode.LIGHT -> false
|
||||
AppThemeMode.DARK -> true
|
||||
AppThemeMode.SYSTEM -> isSystemDark
|
||||
}
|
||||
}
|
||||
|
||||
fun sharedAppTextDimFactor(
|
||||
darkTheme: Boolean,
|
||||
lightFactor: Float,
|
||||
darkFactor: Float
|
||||
): Float {
|
||||
return if (darkTheme) darkFactor else lightFactor
|
||||
}
|
||||
|
||||
fun sharedAppColorScheme(
|
||||
darkTheme: Boolean,
|
||||
seedColor: Color?,
|
||||
contrastLevel: Double,
|
||||
textDimFactor: Float
|
||||
): ColorScheme {
|
||||
val baseColorScheme = seedColor?.let {
|
||||
dynamicColorScheme(
|
||||
seedColor = it,
|
||||
isDark = darkTheme,
|
||||
contrastLevel = contrastLevel,
|
||||
style = PaletteStyle.Fidelity
|
||||
)
|
||||
} ?: if (darkTheme) {
|
||||
SharedDarkColorScheme
|
||||
} else {
|
||||
SharedLightColorScheme
|
||||
}
|
||||
|
||||
return baseColorScheme.withTextDimFactor(textDimFactor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAppThemeSettingsDialog(
|
||||
appThemeMode: AppThemeMode,
|
||||
appContrastOption: AppContrastOption,
|
||||
appTextDimFactorLight: Float,
|
||||
appTextDimFactorDark: Float,
|
||||
appSeedColor: Color?,
|
||||
customAppThemes: List<CustomAppTheme>,
|
||||
onThemeModeChanged: (AppThemeMode) -> Unit,
|
||||
onContrastOptionChanged: (AppContrastOption) -> Unit,
|
||||
onTextDimFactorLightChanged: (Float) -> Unit,
|
||||
onTextDimFactorDarkChanged: (Float) -> Unit,
|
||||
onSeedColorChanged: (Color?) -> Unit,
|
||||
onCustomThemeAdded: (CustomAppTheme) -> Unit,
|
||||
onCustomThemeDeleted: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("App theme", fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 620.dp)
|
||||
.heightIn(max = 620.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SettingsLabel("Appearance")
|
||||
SegmentedControl(
|
||||
values = AppThemeMode.entries,
|
||||
selectedValue = appThemeMode,
|
||||
label = { it.label },
|
||||
onValueSelected = onThemeModeChanged
|
||||
)
|
||||
|
||||
SettingsLabel("Contrast")
|
||||
SegmentedControl(
|
||||
values = AppContrastOption.entries,
|
||||
selectedValue = appContrastOption,
|
||||
label = { it.label },
|
||||
onValueSelected = onContrastOptionChanged
|
||||
)
|
||||
|
||||
if (appThemeMode == AppThemeMode.SYSTEM) {
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness (Light)",
|
||||
value = appTextDimFactorLight,
|
||||
onValueChange = onTextDimFactorLightChanged
|
||||
)
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness (Dark)",
|
||||
value = appTextDimFactorDark,
|
||||
onValueChange = onTextDimFactorDarkChanged
|
||||
)
|
||||
} else {
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness",
|
||||
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
|
||||
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
|
||||
)
|
||||
}
|
||||
|
||||
SettingsLabel("Color scheme")
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ThemeSwatch(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
selected = appSeedColor == null,
|
||||
label = "Dynamic",
|
||||
onClick = { onSeedColorChanged(null) }
|
||||
)
|
||||
AppThemePresets.forEach { preset ->
|
||||
ThemeSwatch(
|
||||
color = preset.color,
|
||||
selected = appSeedColor == preset.color,
|
||||
label = preset.name,
|
||||
onClick = { onSeedColorChanged(preset.color) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SettingsLabel("My themes")
|
||||
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add custom theme")
|
||||
}
|
||||
}
|
||||
|
||||
if (customAppThemes.isEmpty()) {
|
||||
Text(
|
||||
"No custom themes yet",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
customAppThemes.forEach { theme ->
|
||||
ThemeSwatch(
|
||||
color = theme.seedColor,
|
||||
selected = appSeedColor == theme.seedColor,
|
||||
label = theme.name,
|
||||
onClick = { onSeedColorChanged(theme.seedColor) },
|
||||
onDelete = { onCustomThemeDeleted(theme.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (showCreateDialog) {
|
||||
SharedCreateAppThemeDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onSave = { name, color ->
|
||||
onCustomThemeAdded(
|
||||
CustomAppTheme(
|
||||
id = Random.nextLong().toString(),
|
||||
name = name.ifBlank { "Custom" },
|
||||
seedColor = color
|
||||
)
|
||||
)
|
||||
showCreateDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsLabel(label: String) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> SegmentedControl(
|
||||
values: List<T>,
|
||||
selectedValue: T,
|
||||
label: (T) -> String,
|
||||
onValueSelected: (T) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp))
|
||||
.padding(4.dp)
|
||||
) {
|
||||
values.forEach { value ->
|
||||
val selected = selectedValue == value
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||
.clickable { onValueSelected(value) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = label(value),
|
||||
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextBrightnessSlider(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SettingsLabel(label)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"A",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
Slider(
|
||||
value = value.coerceIn(0.3f, 1.0f),
|
||||
onValueChange = { onValueChange(it.coerceIn(0.3f, 1.0f)) },
|
||||
valueRange = 0.3f..1.0f,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 16.dp)
|
||||
)
|
||||
Text(
|
||||
"A",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeSwatch(
|
||||
color: Color,
|
||||
selected: Boolean,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
onDelete: (() -> Unit)? = null
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.border(
|
||||
width = if (selected) 3.dp else 1.dp,
|
||||
color = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 72.dp)
|
||||
)
|
||||
if (onDelete != null) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp).clickable(onClick = onDelete)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedCreateAppThemeDialog(
|
||||
initialColor: Color = Color(0xFF6750A4),
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, Color) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val color = hsv.toComposeColor()
|
||||
|
||||
fun updateFromColor(nextColor: Color) {
|
||||
hsv = nextColor.toSharedHsvColor()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Create theme") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 560.dp).verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Theme name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
SharedSpectrumBox(
|
||||
hue = hsv.hue,
|
||||
saturation = hsv.saturation,
|
||||
currentColor = color,
|
||||
onHueSatChanged = { hue, saturation ->
|
||||
hsv = hsv.copy(hue = hue, saturation = saturation)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(220.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onSave(name.trim().ifBlank { "Custom" }, color) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = color,
|
||||
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
) {
|
||||
Text("Save", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSpectrumBox(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val rainbowColors = listOf(
|
||||
Color.Red,
|
||||
Color.Yellow,
|
||||
Color.Green,
|
||||
Color.Cyan,
|
||||
Color.Blue,
|
||||
Color.Magenta,
|
||||
Color.Red
|
||||
)
|
||||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val activeWidth = size.width.toFloat() - (paddingPx * 2)
|
||||
val activeHeight = size.height.toFloat() - (paddingPx * 2)
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val relativeX = offset.x - paddingPx
|
||||
val relativeY = offset.y - paddingPx
|
||||
val nextHue = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
|
||||
val nextSaturation = (relativeY / activeHeight).coerceIn(0f, 1f)
|
||||
onHueSatChanged(nextHue, nextSaturation)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(touchPadding)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
drawRect(brush = Brush.horizontalGradient(rainbowColors))
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
val activeWidth = size.width - (paddingPx * 2)
|
||||
val activeHeight = size.height - (paddingPx * 2)
|
||||
val x = paddingPx + (hue / 360f) * activeWidth
|
||||
val y = paddingPx + saturation * activeHeight
|
||||
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(x, y + 1.dp.toPx())
|
||||
)
|
||||
drawCircle(
|
||||
color = currentColor.copy(alpha = 1f),
|
||||
radius = pointerRadius,
|
||||
center = Offset(x, y)
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = pointerRadius,
|
||||
center = Offset(x, y),
|
||||
style = Stroke(width = strokeWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedBrightnessSlider(
|
||||
hue: Float,
|
||||
saturation: Float,
|
||||
value: Float,
|
||||
onValueChanged: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val baseColor = remember(hue, saturation) {
|
||||
Color.hsv(hue, saturation, 1f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val nextValue = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onValueChanged(nextValue)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(Color.Black, baseColor)
|
||||
)
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 8.dp.toPx(),
|
||||
center = Offset(value.coerceIn(0f, 1f) * size.width, size.height / 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedRgbInputColumn(
|
||||
label: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val intValue = (value.coerceIn(0f, 1f) * 255).roundToInt()
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.Gray,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SharedRgbInput(value = intValue, onValueChange = onValueChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedRgbInput(
|
||||
value: Int,
|
||||
onValueChange: (Float) -> Unit
|
||||
) {
|
||||
var text by remember(value) { mutableStateOf(value.coerceIn(0, 255).toString()) }
|
||||
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
if (newText.length <= 3 && newText.all { it.isDigit() }) {
|
||||
text = newText
|
||||
newText.toIntOrNull()?.let { channel ->
|
||||
onValueChange(channel.coerceIn(0, 255) / 255f)
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 13.sp
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(Color.White),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 9.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedHexInput(
|
||||
color: Color,
|
||||
onHexChanged: (Color) -> Unit
|
||||
) {
|
||||
val hexValue = color.toSharedHexString().removePrefix("#")
|
||||
var text by remember(hexValue) { mutableStateOf(hexValue) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
color = Color.Gray,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { newText ->
|
||||
if (newText.length <= 6) {
|
||||
val uppercased = newText.uppercase()
|
||||
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
|
||||
text = uppercased
|
||||
if (uppercased.length == 6) {
|
||||
uppercased.toSharedHexColorOrNull()?.let(onHexChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Start,
|
||||
fontSize = 13.sp
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(Color.White),
|
||||
modifier = Modifier
|
||||
.padding(start = 2.dp)
|
||||
.width(50.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedColorComparePill(
|
||||
oldColor: Color,
|
||||
newColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
|
||||
drawRect(
|
||||
color = oldColor.copy(alpha = 1f),
|
||||
size = Size(size.width / 2, size.height)
|
||||
)
|
||||
drawRect(
|
||||
color = newColor.copy(alpha = 1f),
|
||||
topLeft = Offset(size.width / 2, 0f),
|
||||
size = Size(size.width / 2, size.height)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ColorScheme.withTextDimFactor(factor: Float): ColorScheme {
|
||||
val dimFactor = factor.coerceIn(0.3f, 1.0f)
|
||||
if (dimFactor >= 1.0f) return this
|
||||
return copy(
|
||||
primary = primary.copy(alpha = dimFactor),
|
||||
secondary = secondary.copy(alpha = dimFactor),
|
||||
tertiary = tertiary.copy(alpha = dimFactor),
|
||||
error = error.copy(alpha = dimFactor),
|
||||
primaryContainer = primaryContainer.copy(alpha = dimFactor),
|
||||
secondaryContainer = secondaryContainer.copy(alpha = dimFactor),
|
||||
tertiaryContainer = tertiaryContainer.copy(alpha = dimFactor),
|
||||
errorContainer = errorContainer.copy(alpha = dimFactor),
|
||||
outline = outline.copy(alpha = dimFactor),
|
||||
outlineVariant = outlineVariant.copy(alpha = dimFactor),
|
||||
inversePrimary = inversePrimary.copy(alpha = dimFactor),
|
||||
inverseOnSurface = inverseOnSurface.copy(alpha = dimFactor),
|
||||
onPrimary = onPrimary.copy(alpha = dimFactor),
|
||||
onSecondary = onSecondary.copy(alpha = dimFactor),
|
||||
onTertiary = onTertiary.copy(alpha = dimFactor),
|
||||
onBackground = onBackground.copy(alpha = dimFactor),
|
||||
onSurface = onSurface.copy(alpha = dimFactor),
|
||||
onSurfaceVariant = onSurfaceVariant.copy(alpha = dimFactor),
|
||||
onError = onError.copy(alpha = dimFactor),
|
||||
onPrimaryContainer = onPrimaryContainer.copy(alpha = dimFactor),
|
||||
onSecondaryContainer = onSecondaryContainer.copy(alpha = dimFactor),
|
||||
onTertiaryContainer = onTertiaryContainer.copy(alpha = dimFactor),
|
||||
onErrorContainer = onErrorContainer.copy(alpha = dimFactor)
|
||||
)
|
||||
}
|
||||
|
||||
private data class AppThemePreset(
|
||||
val name: String,
|
||||
val color: Color
|
||||
)
|
||||
|
||||
private val AppThemePresets = listOf(
|
||||
AppThemePreset("Ocean", Color(0xFF00668B)),
|
||||
AppThemePreset("Mint", Color(0xFF006C4C)),
|
||||
AppThemePreset("Rose", Color(0xFF9C4146)),
|
||||
AppThemePreset("Sepia", Color(0xFF705D49)),
|
||||
AppThemePreset("Amethyst", Color(0xFF9B59B6)),
|
||||
AppThemePreset("Amber", Color(0xFFFFC107)),
|
||||
AppThemePreset("Sapphire", Color(0xFF0F52BA))
|
||||
)
|
||||
|
||||
private val AppThemeMode.label: String
|
||||
get() = when (this) {
|
||||
AppThemeMode.SYSTEM -> "System"
|
||||
AppThemeMode.LIGHT -> "Light"
|
||||
AppThemeMode.DARK -> "Dark"
|
||||
}
|
||||
|
||||
private val AppContrastOption.label: String
|
||||
get() = when (this) {
|
||||
AppContrastOption.STANDARD -> "Standard"
|
||||
AppContrastOption.MEDIUM -> "Medium"
|
||||
AppContrastOption.HIGH -> "High"
|
||||
}
|
||||
|
||||
internal data class SharedHsvColor(
|
||||
val hue: Float,
|
||||
val saturation: Float,
|
||||
val value: Float
|
||||
) {
|
||||
fun toComposeColor(): Color {
|
||||
return Color.hsv(
|
||||
hue.normalizedHue(),
|
||||
saturation.coerceIn(0f, 1f),
|
||||
value.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Color.toSharedHsvColor(): SharedHsvColor {
|
||||
val maximum = maxOf(red, green, blue)
|
||||
val minimum = minOf(red, green, blue)
|
||||
val delta = maximum - minimum
|
||||
val hue = when {
|
||||
delta == 0f -> 0f
|
||||
maximum == red -> 60f * (((green - blue) / delta) % 6f)
|
||||
maximum == green -> 60f * (((blue - red) / delta) + 2f)
|
||||
else -> 60f * (((red - green) / delta) + 4f)
|
||||
}
|
||||
val saturation = if (maximum == 0f) 0f else delta / maximum
|
||||
return SharedHsvColor(
|
||||
hue = hue.normalizedHue(),
|
||||
saturation = saturation.coerceIn(0f, 1f),
|
||||
value = maximum.coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Color.toSharedHexString(): String {
|
||||
val rgb = toArgb() and 0x00FFFFFF
|
||||
return "#${rgb.toString(16).padStart(6, '0').uppercase()}"
|
||||
}
|
||||
|
||||
internal fun String.toSharedHexColorOrNull(): Color? {
|
||||
val normalized = trim().removePrefix("#")
|
||||
if (normalized.length != 6 || normalized.any { !it.isDigit() && it.lowercaseChar() !in 'a'..'f' }) {
|
||||
return null
|
||||
}
|
||||
val rgb = normalized.toLongOrNull(16) ?: return null
|
||||
return Color((0xFF000000L or rgb).toInt())
|
||||
}
|
||||
|
||||
private fun Float.normalizedHue(): Float {
|
||||
return ((this % 360f) + 360f) % 360f
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.Tag
|
||||
import com.aryan.reader.shared.cardTitle
|
||||
import com.aryan.reader.shared.formatFileSize
|
||||
import com.aryan.reader.shared.parseTagList
|
||||
|
||||
@Composable
|
||||
fun SharedTextInputDialog(
|
||||
title: String,
|
||||
label: String,
|
||||
initialValue: String,
|
||||
confirmLabel: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit
|
||||
) {
|
||||
var value by remember(initialValue) { mutableStateOf(initialValue) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(value) }, enabled = value.isNotBlank()) {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedConfirmDialog(
|
||||
title: String,
|
||||
body: String,
|
||||
confirmLabel: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(body) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAddToShelfDialog(
|
||||
shelves: List<Shelf>,
|
||||
onDismiss: () -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
onShelfSelected: (Shelf) -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add to shelf") },
|
||||
text = {
|
||||
if (shelves.isEmpty()) {
|
||||
Text("Create a shelf first, then add selected books to it.")
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(shelves, key = { it.id }) { shelf ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().clickable { onShelfSelected(shelf) }
|
||||
) {
|
||||
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.bookCount}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onCreateShelf) {
|
||||
Text("New shelf")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedBookInfoDialog(
|
||||
book: BookItem,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: () -> 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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@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 }) }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
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.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.ReaderMarkdownBlock
|
||||
import com.aryan.reader.shared.ReaderMarkdownParser
|
||||
|
||||
@Composable
|
||||
fun SharedMarkdownText(
|
||||
markdown: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = MaterialTheme.typography.bodySmall
|
||||
) {
|
||||
val document = remember(markdown) { ReaderMarkdownParser.parse(markdown) }
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
document.blocks.forEachIndexed { index, block ->
|
||||
when (block) {
|
||||
is ReaderMarkdownBlock.Heading -> {
|
||||
val headingStyle = when (block.level) {
|
||||
1 -> MaterialTheme.typography.titleLarge
|
||||
2 -> MaterialTheme.typography.titleMedium
|
||||
else -> MaterialTheme.typography.titleSmall
|
||||
}
|
||||
Text(
|
||||
text = block.text.markdownInlineAnnotatedString(),
|
||||
style = headingStyle,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.Paragraph -> {
|
||||
Text(text = block.text.markdownInlineAnnotatedString(), style = style)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.Quote -> {
|
||||
Text(
|
||||
text = block.text.markdownInlineAnnotatedString(),
|
||||
style = style,
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.surfaceVariant.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.CodeBlock -> {
|
||||
Surface(
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = block.text,
|
||||
style = style.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ReaderMarkdownBlock.ListItems -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
block.items.forEachIndexed { itemIndex, item ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = if (block.ordered) "${itemIndex + 1}." else "-",
|
||||
style = style,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = item.markdownInlineAnnotatedString(),
|
||||
style = style,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (document.blocks.isEmpty() && markdown.isNotBlank()) {
|
||||
Text(text = markdown, style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun String.markdownInlineAnnotatedString(): AnnotatedString {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
return remember(this, colorScheme.primary, colorScheme.surfaceVariant) {
|
||||
buildAnnotatedString {
|
||||
appendMarkdownInline(
|
||||
text = this@markdownInlineAnnotatedString,
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
),
|
||||
linkStyle = SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendMarkdownInline(
|
||||
text: String,
|
||||
codeStyle: SpanStyle,
|
||||
linkStyle: SpanStyle
|
||||
) {
|
||||
var index = 0
|
||||
while (index < text.length) {
|
||||
when {
|
||||
text.startsWith("`", index) -> {
|
||||
val end = text.indexOf('`', startIndex = index + 1)
|
||||
if (end > index) {
|
||||
withStyle(codeStyle) { append(text.substring(index + 1, end)) }
|
||||
index = end + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text.startsWith("**", index) -> {
|
||||
val end = text.indexOf("**", startIndex = index + 2)
|
||||
if (end > index) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
appendMarkdownInline(text.substring(index + 2, end), codeStyle, linkStyle)
|
||||
}
|
||||
index = end + 2
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text.startsWith("*", index) -> {
|
||||
val end = text.indexOf('*', startIndex = index + 1)
|
||||
if (end > index) {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendMarkdownInline(text.substring(index + 1, end), codeStyle, linkStyle)
|
||||
}
|
||||
index = end + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text[index] == '[' -> {
|
||||
val labelEnd = text.indexOf("](", startIndex = index + 1)
|
||||
val urlEnd = if (labelEnd > index) text.indexOf(')', startIndex = labelEnd + 2) else -1
|
||||
if (labelEnd > index && urlEnd > labelEnd) {
|
||||
withStyle(linkStyle) {
|
||||
appendMarkdownInline(text.substring(index + 1, labelEnd), codeStyle, linkStyle)
|
||||
}
|
||||
index = urlEnd + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,841 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
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
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.opds.OpdsAcquisition
|
||||
import com.aryan.reader.shared.opds.OpdsCatalog
|
||||
import com.aryan.reader.shared.opds.OpdsEntry
|
||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadState
|
||||
import com.aryan.reader.shared.opds.SharedOpdsScreenState
|
||||
import com.aryan.reader.shared.opds.SharedOpdsText
|
||||
|
||||
@Composable
|
||||
fun SharedOpdsScreen(
|
||||
state: SharedOpdsScreenState,
|
||||
localLibraryBooks: List<BookItem>,
|
||||
onOpenCatalog: (OpdsCatalog) -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onLoadNextPage: () -> Unit,
|
||||
onAddCatalog: (String, String, String?, String?) -> Unit,
|
||||
onUpdateCatalog: (String, String, String, String?, String?) -> Unit,
|
||||
onRemoveCatalog: (OpdsCatalog) -> Unit,
|
||||
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedEntry by remember { mutableStateOf<OpdsEntry?>(null) }
|
||||
var showCatalogDialog by remember { mutableStateOf(false) }
|
||||
var editingCatalog by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||
var catalogToDelete by remember { mutableStateOf<OpdsCatalog?>(null) }
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
if (!state.isViewingCatalog) {
|
||||
SharedOpdsCatalogList(
|
||||
catalogs = state.catalogs,
|
||||
onOpenCatalog = onOpenCatalog,
|
||||
onEditCatalog = { catalog ->
|
||||
editingCatalog = catalog
|
||||
showCatalogDialog = true
|
||||
},
|
||||
onDeleteCatalog = { catalogToDelete = it },
|
||||
onAddCatalog = {
|
||||
editingCatalog = null
|
||||
showCatalogDialog = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SharedOpdsFeedView(
|
||||
state = state,
|
||||
localLibraryBooks = localLibraryBooks,
|
||||
onNavigateBack = onNavigateBack,
|
||||
onSearch = onSearch,
|
||||
onOpenFeedUrl = onOpenFeedUrl,
|
||||
onLoadNextPage = onLoadNextPage,
|
||||
onDownloadBook = onDownloadBook,
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { entry -> onStreamBook(entry, state.currentCatalog) },
|
||||
onEntrySelected = { selectedEntry = it }
|
||||
)
|
||||
}
|
||||
|
||||
state.errorMessage?.let { error ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = onClearError) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCatalogDialog) {
|
||||
SharedOpdsCatalogDialog(
|
||||
catalog = editingCatalog,
|
||||
onDismiss = {
|
||||
showCatalogDialog = false
|
||||
editingCatalog = null
|
||||
},
|
||||
onSave = { title, url, username, password ->
|
||||
val editing = editingCatalog
|
||||
if (editing == null) {
|
||||
onAddCatalog(title, url, username, password)
|
||||
} else {
|
||||
onUpdateCatalog(editing.id, title, url, username, password)
|
||||
}
|
||||
showCatalogDialog = false
|
||||
editingCatalog = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
catalogToDelete?.let { catalog ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { catalogToDelete = null },
|
||||
title = { Text("Delete catalog") },
|
||||
text = { Text("Delete \"${catalog.title}\"? Streamed books from this catalog may stop opening if credentials change later.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onRemoveCatalog(catalog)
|
||||
catalogToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { catalogToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
selectedEntry?.let { entry ->
|
||||
SharedOpdsEntryDetailsDialog(
|
||||
entry = entry,
|
||||
localLibraryBook = entry.findLocalBook(localLibraryBooks),
|
||||
downloadState = state.downloadingState[entry.id],
|
||||
onDismiss = { selectedEntry = null },
|
||||
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = {
|
||||
onStreamBook(entry, state.currentCatalog)
|
||||
selectedEntry = null
|
||||
},
|
||||
onOpenFeedUrl = { url ->
|
||||
onOpenFeedUrl(url)
|
||||
selectedEntry = null
|
||||
},
|
||||
onSearch = { query ->
|
||||
onSearch(query)
|
||||
selectedEntry = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogList(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
onOpenCatalog: (OpdsCatalog) -> Unit,
|
||||
onEditCatalog: (OpdsCatalog) -> Unit,
|
||||
onDeleteCatalog: (OpdsCatalog) -> Unit,
|
||||
onAddCatalog: () -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SharedScreenScaffold(
|
||||
title = "OPDS",
|
||||
subtitle = "Browse catalogs, streams, and downloads",
|
||||
trailing = {
|
||||
Button(onClick = onAddCatalog) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Catalog")
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (catalogs.isEmpty()) {
|
||||
SharedOpdsEmptyState(onAddCatalog = onAddCatalog, modifier = Modifier.weight(1f))
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(320.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(catalogs, key = { it.id }) { catalog ->
|
||||
SharedOpdsCatalogCard(
|
||||
catalog = catalog,
|
||||
onOpenCatalog = { onOpenCatalog(catalog) },
|
||||
onEditCatalog = { onEditCatalog(catalog) },
|
||||
onDeleteCatalog = { onDeleteCatalog(catalog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsFeedView(
|
||||
state: SharedOpdsScreenState,
|
||||
localLibraryBooks: List<BookItem>,
|
||||
onNavigateBack: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onLoadNextPage: () -> Unit,
|
||||
onDownloadBook: (OpdsEntry, OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: (OpdsEntry) -> Unit,
|
||||
onEntrySelected: (OpdsEntry) -> Unit
|
||||
) {
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
if (showSearch) {
|
||||
showSearch = false
|
||||
query = ""
|
||||
} else {
|
||||
onNavigateBack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
if (showSearch) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search catalog") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (query.isNotBlank()) {
|
||||
onSearch(query)
|
||||
query = ""
|
||||
showSearch = false
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = state.currentFeed?.title ?: "Loading",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
state.currentCatalog?.title?.let { catalogTitle ->
|
||||
Text(
|
||||
catalogTitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.searchUrlTemplate != null) {
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.isLoading) {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val facets = state.currentFeed?.facets.orEmpty()
|
||||
if (facets.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
facets.groupBy { it.group }.forEach { (groupName, groupFacets) ->
|
||||
item(key = groupName) {
|
||||
SharedOpdsFacetMenu(
|
||||
groupName = groupName,
|
||||
facets = groupFacets,
|
||||
onOpenFeedUrl = onOpenFeedUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val entries = state.currentFeed?.entries.orEmpty()
|
||||
if (entries.isEmpty() && !state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("This feed is empty.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
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 {
|
||||
SharedOpdsBookCard(
|
||||
entry = entry,
|
||||
localLibraryBook = entry.findLocalBook(localLibraryBooks),
|
||||
downloadState = state.downloadingState[entry.id],
|
||||
onDownloadBook = { acquisition -> onDownloadBook(entry, acquisition) },
|
||||
onReadBook = onReadBook,
|
||||
onStreamBook = { onStreamBook(entry) },
|
||||
onClick = { onEntrySelected(entry) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogCard(
|
||||
catalog: OpdsCatalog,
|
||||
onOpenCatalog: () -> Unit,
|
||||
onEditCatalog: () -> Unit,
|
||||
onDeleteCatalog: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
onClick = onOpenCatalog,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
catalog.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (catalog.isDefault) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text(
|
||||
"Preset",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (!catalog.isDefault) {
|
||||
IconButton(onClick = onEditCatalog) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||
}
|
||||
IconButton(onClick = onDeleteCatalog) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsEmptyState(onAddCatalog: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text("No catalogs", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Add an OPDS catalog to browse remote books.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Button(onClick = onAddCatalog) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add catalog")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsFacetMenu(
|
||||
groupName: String,
|
||||
facets: List<com.aryan.reader.shared.opds.OpdsFacet>,
|
||||
onOpenFeedUrl: (String) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val activeFacet = facets.firstOrNull { it.isActive } ?: facets.firstOrNull()
|
||||
Box {
|
||||
FilterChip(
|
||||
selected = activeFacet?.isActive == true,
|
||||
onClick = { expanded = true },
|
||||
label = { Text("$groupName: ${activeFacet?.title ?: "Select"}") },
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }
|
||||
)
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
facets.forEach { facet ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(facet.title) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onOpenFeedUrl(facet.url)
|
||||
},
|
||||
trailingIcon = if (facet.isActive) {
|
||||
{ Icon(Icons.Default.Check, contentDescription = null) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsNavigationCard(entry: OpdsEntry, onOpenFeedUrl: (String) -> Unit) {
|
||||
Surface(
|
||||
onClick = { entry.navigationUrl?.let(onOpenFeedUrl) },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsBookCard(
|
||||
entry: OpdsEntry,
|
||||
localLibraryBook: BookItem?,
|
||||
downloadState: SharedOpdsDownloadState?,
|
||||
onDownloadBook: (OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
}
|
||||
val isDownloading = downloadState?.isDownloading == true
|
||||
var showFormatMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
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)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.author?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(summary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
when {
|
||||
localLibraryBook != null -> {
|
||||
OutlinedButton(onClick = { onReadBook(localLibraryBook) }, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Read")
|
||||
}
|
||||
}
|
||||
isDownloading -> SharedOpdsDownloadProgress(downloadState)
|
||||
else -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (entry.isStreamable) {
|
||||
FilledTonalButton(onClick = onStreamBook, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Stream")
|
||||
}
|
||||
}
|
||||
Box {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
when (uniqueAcquisitions.size) {
|
||||
0 -> Unit
|
||||
1 -> onDownloadBook(uniqueAcquisitions.first())
|
||||
else -> showFormatMenu = true
|
||||
}
|
||||
},
|
||||
enabled = uniqueAcquisitions.isNotEmpty(),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (uniqueAcquisitions.isEmpty()) Icons.Default.Info else Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (uniqueAcquisitions.isEmpty()) "Unavailable" else "Download")
|
||||
}
|
||||
DropdownMenu(expanded = showFormatMenu, onDismissRequest = { showFormatMenu = false }) {
|
||||
uniqueAcquisitions.forEach { acquisition ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(acquisition.formatName) },
|
||||
onClick = {
|
||||
showFormatMenu = false
|
||||
onDownloadBook(acquisition)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsDownloadProgress(downloadState: SharedOpdsDownloadState?) {
|
||||
val progress = downloadState?.progress
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Downloading", style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (progress != null) {
|
||||
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (progress != null) {
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsEntryDetailsDialog(
|
||||
entry: OpdsEntry,
|
||||
localLibraryBook: BookItem?,
|
||||
downloadState: SharedOpdsDownloadState?,
|
||||
onDismiss: () -> Unit,
|
||||
onDownloadBook: (OpdsAcquisition) -> Unit,
|
||||
onReadBook: (BookItem) -> Unit,
|
||||
onStreamBook: () -> Unit,
|
||||
onOpenFeedUrl: (String) -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
val uniqueAcquisitions = remember(entry.acquisitions) {
|
||||
entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Column {
|
||||
Text(entry.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.author?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 520.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
localLibraryBook?.let { book ->
|
||||
Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Read")
|
||||
}
|
||||
}
|
||||
if (downloadState?.isDownloading == true) {
|
||||
SharedOpdsDownloadProgress(downloadState)
|
||||
} else {
|
||||
if (entry.isStreamable) {
|
||||
Button(onClick = onStreamBook, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Stream now")
|
||||
}
|
||||
}
|
||||
if (uniqueAcquisitions.isNotEmpty()) {
|
||||
Text("Download format", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
uniqueAcquisitions.take(4).forEach { acquisition ->
|
||||
FilledTonalButton(onClick = { onDownloadBook(acquisition) }) {
|
||||
Text(acquisition.formatName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
entry.authors.forEach { author ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (author.url != null) onOpenFeedUrl(author.url) else onSearch(author.name)
|
||||
}
|
||||
) {
|
||||
Text(author.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.categories.isNotEmpty()) {
|
||||
Text("Categories", style = MaterialTheme.typography.labelLarge)
|
||||
entry.categories.distinct().take(8).forEach { category ->
|
||||
TextButton(onClick = { onSearch(category) }) {
|
||||
Text(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
Text(summary, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedOpdsCatalogDialog(
|
||||
catalog: OpdsCatalog?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, String, String?, String?) -> Unit
|
||||
) {
|
||||
var title by remember(catalog) { mutableStateOf(catalog?.title.orEmpty()) }
|
||||
var url by remember(catalog) { mutableStateOf(catalog?.url.orEmpty()) }
|
||||
var username by remember(catalog) { mutableStateOf(catalog?.username.orEmpty()) }
|
||||
var password by remember(catalog) { mutableStateOf(catalog?.password.orEmpty()) }
|
||||
val isEditMode = catalog != null
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
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)
|
||||
Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
OutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onSave(title, url, username, password) },
|
||||
enabled = title.isNotBlank() && url.isNotBlank()
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem? {
|
||||
return localLibraryBooks.firstOrNull {
|
||||
it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,261 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextController
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
|
||||
import com.aryan.reader.shared.pdf.withoutTrailingSharedPdfPageBreak
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SharedPdfRichTextHiddenInput(
|
||||
controller: SharedPdfRichTextController,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LaunchedEffect(enabled, controller.activePageIndex) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.hiddenInput enabled=$enabled activePage=${controller.activePageIndex} " +
|
||||
"editingLen=${controller.editingValue.text.length} selection=${controller.editingValue.selection}"
|
||||
)
|
||||
if (enabled && controller.activePageIndex != -1) {
|
||||
controller.requestEditingFocus()
|
||||
delay(16)
|
||||
controller.requestEditingFocus()
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled) return
|
||||
|
||||
BasicTextField(
|
||||
value = controller.editingValue,
|
||||
onValueChange = controller::onValueChanged,
|
||||
textStyle = TextStyle(
|
||||
color = controller.currentStyle.color,
|
||||
fontSize = controller.currentStyle.fontSize,
|
||||
fontWeight = controller.currentStyle.fontWeight,
|
||||
fontStyle = controller.currentStyle.fontStyle,
|
||||
textDecoration = controller.currentStyle.textDecoration
|
||||
),
|
||||
modifier = modifier
|
||||
.size(1.dp)
|
||||
.alpha(0f)
|
||||
.clearAndSetSemantics { }
|
||||
.focusRequester(controller.focusRequester)
|
||||
.onKeyEvent { event ->
|
||||
event.type == KeyEventType.KeyDown &&
|
||||
event.key == Key.Backspace &&
|
||||
controller.handleBackspaceAtStart()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedPdfRichTextLayer(
|
||||
pageIndex: Int,
|
||||
controller: SharedPdfRichTextController,
|
||||
pageWidth: Float,
|
||||
pageHeight: Float,
|
||||
isTextEditingEnabled: Boolean,
|
||||
centeringOffsetX: Float = 0f,
|
||||
centeringOffsetY: Float = 0f,
|
||||
isDarkMode: Boolean = false,
|
||||
isScrolling: Boolean = false,
|
||||
onPageTapped: (Int) -> Unit = {}
|
||||
) {
|
||||
LaunchedEffect(pageIndex, pageWidth, pageHeight, isTextEditingEnabled) {
|
||||
if (pageWidth <= 0f || pageHeight <= 0f) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer invalidSize page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " +
|
||||
"editing=$isTextEditingEnabled"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (pageWidth <= 0f || pageHeight <= 0f) return
|
||||
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
||||
LaunchedEffect(pageWidth, pageHeight, density, textMeasurer) {
|
||||
controller.updateLayoutConfig(pageWidth, pageHeight, density, textMeasurer)
|
||||
}
|
||||
|
||||
val pageLayout = remember(controller.pageLayouts, pageIndex) {
|
||||
controller.pageLayouts.find { it.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
pageIndex,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
isTextEditingEnabled,
|
||||
controller.activePageIndex,
|
||||
pageLayout?.globalStartIndex,
|
||||
pageLayout?.globalEndIndex
|
||||
) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer page=$pageIndex size=${pageWidth.richTextUiFloat()}x${pageHeight.richTextUiFloat()} " +
|
||||
"editing=$isTextEditingEnabled activePage=${controller.activePageIndex} " +
|
||||
"layout=${pageLayout?.globalStartIndex}-${pageLayout?.globalEndIndex} " +
|
||||
"visibleLen=${pageLayout?.visibleText?.length ?: 0}"
|
||||
)
|
||||
}
|
||||
|
||||
val marginX = pageWidth * 0.1f
|
||||
val marginY = pageHeight * 0.08f
|
||||
val editorWidth = (pageWidth - (marginX * 2f)).coerceAtLeast(10f)
|
||||
val editorHeight = (pageHeight - (marginY * 2f)).coerceAtLeast(10f)
|
||||
val editorWidthDp = with(density) { editorWidth.toDp() }
|
||||
val editorHeightDp = with(density) { editorHeight.toDp() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
(centeringOffsetX + marginX).roundToInt(),
|
||||
(centeringOffsetY + marginY).roundToInt()
|
||||
)
|
||||
}
|
||||
.size(editorWidthDp, editorHeightDp)
|
||||
.graphicsLayer()
|
||||
.clipToBounds()
|
||||
.then(
|
||||
if (isTextEditingEnabled) {
|
||||
Modifier.pointerInput(
|
||||
pageIndex,
|
||||
editorWidth,
|
||||
editorHeight,
|
||||
controller.activePageIndex,
|
||||
pageLayout?.globalStartIndex,
|
||||
pageLayout?.globalEndIndex
|
||||
) {
|
||||
detectTapGestures { tapOffset ->
|
||||
SharedPdfRichTextLog.d(
|
||||
"ui.layer.tap page=$pageIndex offset=${tapOffset.richTextUiOffsetSummary()} " +
|
||||
"editor=${editorWidth.richTextUiFloat()}x${editorHeight.richTextUiFloat()} " +
|
||||
"activePage=${controller.activePageIndex} hasLayout=${pageLayout != null}"
|
||||
)
|
||||
onPageTapped(pageIndex)
|
||||
controller.handleTapOnPage(pageIndex, tapOffset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
val textToRender = if (controller.activePageIndex == pageIndex) {
|
||||
controller.localTextFieldValue.annotatedString
|
||||
} else {
|
||||
pageLayout?.visibleText?.withoutTrailingSharedPdfPageBreak()
|
||||
} ?: return@Box
|
||||
|
||||
val measureResult = remember(textToRender, editorWidth, density) {
|
||||
textMeasurer.measure(
|
||||
text = textToRender,
|
||||
style = TextStyle(fontSize = 16.sp),
|
||||
constraints = Constraints(maxWidth = editorWidth.toInt()),
|
||||
density = density
|
||||
)
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
measureResult.multiParagraph.paint(drawContext.canvas)
|
||||
}
|
||||
|
||||
if (isTextEditingEnabled && controller.activePageIndex == pageIndex) {
|
||||
val selection = controller.editingValue.selection
|
||||
val localStart = selection.start.coerceIn(0, textToRender.length)
|
||||
val localEnd = selection.end.coerceIn(0, textToRender.length)
|
||||
|
||||
if (localStart != localEnd) {
|
||||
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.collapsed && controller.isCursorVisible) {
|
||||
val alpha = if (isScrolling) {
|
||||
1f
|
||||
} else {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pdfRichCursor")
|
||||
infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(tween(500), RepeatMode.Reverse),
|
||||
label = "pdfRichCursorAlpha"
|
||||
).value
|
||||
}
|
||||
val cursorRect = measureResult.getCursorRect(localStart)
|
||||
val styleFontSize = controller.currentStyle.fontSize
|
||||
val cursorHeight = if (styleFontSize.isSpecified) {
|
||||
with(density) { styleFontSize.toPx() } * 1.2f
|
||||
} else {
|
||||
cursorRect.height
|
||||
}
|
||||
val centerY = cursorRect.center.y
|
||||
val cursorColor = if (isDarkMode) Color.White else Color.Black
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawLine(
|
||||
color = cursorColor.copy(alpha = alpha),
|
||||
start = Offset(cursorRect.left, centerY - cursorHeight / 2f),
|
||||
end = Offset(cursorRect.left, centerY + cursorHeight / 2f),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.richTextUiFloat(): String {
|
||||
return if (isFinite()) {
|
||||
val rounded = kotlin.math.round(this * 10f) / 10f
|
||||
rounded.toString()
|
||||
} else {
|
||||
toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.richTextUiOffsetSummary(): String {
|
||||
return "(${x.richTextUiFloat()},${y.richTextUiFloat()})"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,600 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.Column
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
|
||||
@Composable
|
||||
fun SharedCustomFontsScreen(
|
||||
fonts: List<CustomFontItem>,
|
||||
onImportFont: () -> Unit,
|
||||
onDeleteFont: (CustomFontItem) -> Unit,
|
||||
googleFontsAvailable: Boolean = false,
|
||||
getGoogleFonts: () -> List<String> = { emptyList() },
|
||||
onDownloadGoogleFont: (String, () -> Unit) -> Unit = { _, onComplete -> onComplete() },
|
||||
fontFamilyForPreview: (CustomFontItem) -> FontFamily? = { null },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var fontPendingDelete by remember { mutableStateOf<CustomFontItem?>(null) }
|
||||
var showGoogleFontsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SharedScreenScaffold(
|
||||
title = "Custom Fonts",
|
||||
subtitle = "Imported fonts for the reader",
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (googleFontsAvailable) {
|
||||
Button(onClick = { showGoogleFontsDialog = true }) {
|
||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Google Fonts")
|
||||
}
|
||||
}
|
||||
Button(onClick = onImportFont) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val activeFonts = fonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
|
||||
if (activeFonts.isEmpty()) {
|
||||
SharedUtilityEmptyState(
|
||||
icon = { Icon(Icons.Default.TextFields, contentDescription = null, modifier = Modifier.size(56.dp)) },
|
||||
title = "No custom fonts",
|
||||
body = "Import TTF, OTF, or WOFF2 files to use them in books.",
|
||||
actionLabel = "Import font",
|
||||
onAction = onImportFont,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(activeFonts, key = { it.id }) { font ->
|
||||
SharedFontListItem(
|
||||
font = font,
|
||||
onDelete = { fontPendingDelete = font },
|
||||
fontFamilyForPreview = fontFamilyForPreview
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (googleFontsAvailable && showGoogleFontsDialog) {
|
||||
SharedGoogleFontsDialog(
|
||||
existingFonts = fonts,
|
||||
getGoogleFonts = getGoogleFonts,
|
||||
onDownloadGoogleFont = onDownloadGoogleFont,
|
||||
onDismiss = { showGoogleFontsDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
fontPendingDelete?.let { font ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { fontPendingDelete = null },
|
||||
title = { Text("Delete font?") },
|
||||
text = { Text("Delete ${font.displayName}? Books using it will fall back to the default font.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDeleteFont(font)
|
||||
fontPendingDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { fontPendingDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedGoogleFontsDialog(
|
||||
existingFonts: List<CustomFontItem>,
|
||||
getGoogleFonts: () -> List<String>,
|
||||
onDownloadGoogleFont: (String, () -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var downloadingFontName by remember { mutableStateOf<String?>(null) }
|
||||
val popularPresets = remember {
|
||||
listOf(
|
||||
"Merriweather",
|
||||
"Open Sans",
|
||||
"Playfair Display",
|
||||
"Montserrat",
|
||||
"Oswald",
|
||||
"Raleway",
|
||||
"Nunito",
|
||||
"Poppins",
|
||||
"Ubuntu",
|
||||
"Fira Sans",
|
||||
"Quicksand",
|
||||
"Crimson Text",
|
||||
"Literata",
|
||||
"EB Garamond",
|
||||
"Libre Baskerville",
|
||||
"Inter",
|
||||
"Work Sans"
|
||||
)
|
||||
}
|
||||
val displayList = remember(searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
popularPresets
|
||||
} else {
|
||||
getGoogleFonts()
|
||||
.filter { it.contains(searchQuery, ignoreCase = true) }
|
||||
.take(50)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text("Browse Google Fonts", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search 1900+ fonts...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (searchQuery.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Popular choices",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else if (displayList.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No fonts found matching '$searchQuery'",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(displayList, key = { it }) { fontName ->
|
||||
val isDownloaded = existingFonts.any { it.displayName.equals(fontName, ignoreCase = true) }
|
||||
val isDownloading = downloadingFontName == fontName
|
||||
fun startDownload() {
|
||||
downloadingFontName = fontName
|
||||
onDownloadGoogleFont(fontName) {
|
||||
if (downloadingFontName == fontName) {
|
||||
downloadingFontName = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (isDownloaded) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.clickable(enabled = !isDownloaded && !isDownloading) { startDownload() }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = fontName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (isDownloaded) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when {
|
||||
isDownloaded -> Icon(Icons.Default.Check, contentDescription = "Already downloaded", tint = MaterialTheme.colorScheme.primary)
|
||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.CloudDownload, contentDescription = "Download")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedFontListItem(
|
||||
font: CustomFontItem,
|
||||
onDelete: () -> Unit,
|
||||
fontFamilyForPreview: (CustomFontItem) -> FontFamily?
|
||||
) {
|
||||
val previewFontFamily = remember(font.path) { fontFamilyForPreview(font) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
) {
|
||||
Box(Modifier.size(42.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Aa", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = font.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = font.path,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Text(
|
||||
text = font.fileExtension.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete font", tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:",
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 18.sp),
|
||||
fontFamily = previewFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedHelpFeedbackScreen(
|
||||
onOpenGitHubIssues: () -> Unit,
|
||||
onEmailSupport: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "Help & Feedback",
|
||||
subtitle = "Bug reports, feature requests, and support",
|
||||
modifier = modifier
|
||||
) {
|
||||
SharedUtilityHeader(
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(52.dp)) },
|
||||
title = "Get in touch",
|
||||
body = "Report bugs, request features, or contact support directly."
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "GitHub Issues",
|
||||
body = "Report bugs, request features, and track development progress.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenGitHubIssues
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Email Support",
|
||||
body = "Contact us directly by email for anything else.",
|
||||
icon = { Icon(Icons.Default.Email, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onEmailSupport
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedSupportProjectScreen(
|
||||
onOpenGitHubSponsors: () -> Unit,
|
||||
onOpenPatreon: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "Support Project",
|
||||
subtitle = "Ways to support Episteme development",
|
||||
modifier = modifier
|
||||
) {
|
||||
SharedUtilityHeader(
|
||||
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(52.dp)) },
|
||||
title = "Support Episteme",
|
||||
body = "Contributions help keep the reader improving across Android and desktop."
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "GitHub Sponsors",
|
||||
body = "Support development through GitHub Sponsors.",
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenGitHubSponsors
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Patreon",
|
||||
body = "Support the project on Patreon.",
|
||||
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenPatreon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAboutScreen(
|
||||
versionName: String,
|
||||
buildLabel: String,
|
||||
onOpenSource: () -> Unit,
|
||||
onOpenIssues: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "About Episteme",
|
||||
subtitle = "Desktop reader",
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(Modifier.size(52.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Text("Episteme", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(versionName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(buildLabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedUtilityHeader(
|
||||
icon: @Composable () -> Unit,
|
||||
title: String,
|
||||
body: String
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(Modifier.size(58.dp), contentAlignment = Alignment.Center) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedUtilityOptionCard(
|
||||
title: String,
|
||||
body: String,
|
||||
icon: @Composable () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
OutlinedCard(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedUtilityEmptyState(
|
||||
icon: @Composable () -> Unit,
|
||||
title: String,
|
||||
body: String,
|
||||
actionLabel: String,
|
||||
onAction: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Box(Modifier.padding(18.dp), contentAlignment = Alignment.Center) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(0.7f)
|
||||
)
|
||||
TextButton(onClick = onAction) {
|
||||
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue