Windows ga (#358)
* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app * Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app * Persist reader window state and improve slider interaction in desktop app * Improve PDF page transitions and refine focus management in desktop app * Refactor scrollbar interaction and adjust desktop modal focus handling * Improve PDF sidecar synchronization and cross-platform metadata compatibility * Refactor PDF annotation comment logic to shared module and implement Desktop UI * Refactor reader screen to use tap-to-toggle and full-width styling in desktop app * Refactor reader workspace layout and chrome-panel interactions * Implement global search keyboard shortcuts and focusable chrome layers * Implement flavor-specific legal links and update the About UI * Refactor reader UI controls on desktop app * Enhance desktop folder sync with background metadata extraction and improved error handling * Refactor Library UI and remove redundant Home tab in desktop app * Add custom tooltips to reader icon buttons in desktop app * Integrate app theme controls into reader interfaces on desktop * Add right-to-left pagination support and improve focus restoration on desktop app * Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app * Update desktop reader defaults and implement settings migration * Implement block-based position tracking in ReaderLocator * Enhance EPUB highlighting reliability in desktop app * Add support for custom reader themes and update highlight palette logic in desktop app * Replace the Tools panel with a "More" dropdown menu and refactor account UI * Implement account profile header in desktop sidebar * Implement cloud sync reliability improvements and sidebar toggle on desktop app * Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app * Integrate WebView2 for EPUB vertical rendering on Windows * Refactor reader layout logic and enhance WebView2 diagnostics * Improve vertical reading layout and WebView2 resizing on Desktop * Refine vertical reading mode layout and margin handling * Enhance reader locator precision and Desktop mode-switching reliability * Implement chapter-level caching and warm-start pagination in desktop app * Replace bundled KCEF with native system webviews via SWT * Refactor EPUB page info bar visibility and layout logic * Improve PDF toolbar persistence and fix tab reactivation logic * Enable multi-selection and bulk operations for custom fonts * Refactor instrumentation tests * Add EPUB UI test fixture and initial instrumentation tests * Expand EpubReader UI tests and improve accessibility * Add instrumentation tests and test tags for library and reader screens * Enhance OPDS parser logic and catalog integration * Add support for toggling local synchronization on a per-folder basis. * Implement tri-state sizing for the TTS overlay * Persist TTS overlay size across sessions * Refactor reader brightness control and add incremental step buttons * Improve CSS support, pagination control, and style-aware semantic caching * Improve link handling, interaction, and diagnostics in the paginated reader * crash fixes * Implement persistent pending removal for external files * Implement book-specific word replacements * Add native vertical reading mode with custom renderer * Implement text selection and navigation improvements for the native vertical reader * Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub * Implement lazy loading and chapter prefetching for native vertical reader * Improve window lifecycle and disposal handling on Desktop * Optimize vertical reading performance in desktop app * Enhance TTS start accuracy and diagnostic logging on desktop * Refactor AI settings visibility on desktop * Improve pagination height measurement and enhance cutoff diagnostics * Implement lifecycle management and improve justified text splitting for pagination * Refine AI usage tracking and force AI feature visibility on Desktop * Add descriptive context comments and usage examples to string and plural resources. * Optimize performance and memory usage in search and state mapping * Replace reader page sliders with minimal slider and navigation controls * Add support for CBT comic archives * Harden file path validation and XML parsing to prevent security vulnerabilities * Implement local account profile caching and optimize desktop performance * Improve desktop persistence reliability and add Linux secure storage support * Improved PDF zoom stability and layout prediction during zoom commits * Improved PDF spread layout prediction, reader focus restoration, and account profile caching * Enhance highlight precision and scoping using block-local offsets and CFIs * Enhance cloud book content synchronization and background downloads * Implement granular timestamp tracking for reading positions and PDF annotations * Restrict diagnostic logging and stack traces to debug builds * Refine PDF page gaps and reader chrome interaction logic * Refactor PDF highlight rendering and overhaul Desktop sidebar UI * Implement a new interaction dock and undo/redo history for PDF annotations in desktop * Enhance PDF color picker and improve navigation scroll restoration * Add highlight palette customization and improve selection menu UI in desktop app epub reader * Enhance desktop shelf management and library organization
This commit is contained in:
parent
5971eaa571
commit
83dcafa4b6
444 changed files with 47279 additions and 8096 deletions
|
|
@ -0,0 +1,12 @@
|
|||
package androidx.compose.material.icons
|
||||
|
||||
object Icons {
|
||||
object Filled
|
||||
val Default: Filled get() = Filled
|
||||
|
||||
object Outlined
|
||||
|
||||
object AutoMirrored {
|
||||
object Filled
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
@file:Suppress("ObjectPropertyName", "unused")
|
||||
|
||||
package androidx.compose.material.icons.automirrored.filled
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Icons.AutoMirrored.Filled.ArrowBack: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.arrowBack
|
||||
|
||||
val Icons.AutoMirrored.Filled.ArrowForward: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.arrowForward
|
||||
|
||||
val Icons.AutoMirrored.Filled.KeyboardArrowRight: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.keyboardArrowRight
|
||||
|
||||
val Icons.AutoMirrored.Filled.LibraryBooks: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.libraryBooks
|
||||
|
||||
val Icons.AutoMirrored.Filled.List: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.list
|
||||
|
||||
val Icons.AutoMirrored.Filled.MenuBook: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.menuBook
|
||||
|
||||
val Icons.AutoMirrored.Filled.NavigateBefore: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.navigateBefore
|
||||
|
||||
val Icons.AutoMirrored.Filled.NavigateNext: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.navigateNext
|
||||
|
||||
val Icons.AutoMirrored.Filled.OpenInNew: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.openInNew
|
||||
|
||||
val Icons.AutoMirrored.Filled.Redo: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.redo
|
||||
|
||||
val Icons.AutoMirrored.Filled.Sort: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.sort
|
||||
|
||||
val Icons.AutoMirrored.Filled.Undo: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.undo
|
||||
|
||||
val Icons.AutoMirrored.Filled.VolumeUp: ImageVector
|
||||
get() = EpistemeAutoMirroredFilledIcons.volumeUp
|
||||
|
||||
private object EpistemeAutoMirroredFilledIcons {
|
||||
val arrowBack: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "ArrowBack",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val arrowForward: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "ArrowForward",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val keyboardArrowRight: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "KeyboardArrowRight",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val libraryBooks: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "LibraryBooks",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M400,560L560,560L560,480L400,480L400,560ZM400,440L720,440L720,360L400,360L400,440ZM400,320L720,320L720,240L400,240L400,320ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val list: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "List",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M280,360L280,280L840,280L840,360L280,360ZM280,520L280,440L840,440L840,520L280,520ZM280,680L280,600L840,600L840,680L280,680ZM160,360Q143,360 131.5,348.5Q120,337 120,320Q120,303 131.5,291.5Q143,280 160,280Q177,280 188.5,291.5Q200,303 200,320Q200,337 188.5,348.5Q177,360 160,360ZM160,520Q143,520 131.5,508.5Q120,497 120,480Q120,463 131.5,451.5Q143,440 160,440Q177,440 188.5,451.5Q200,463 200,480Q200,497 188.5,508.5Q177,520 160,520ZM160,680Q143,680 131.5,668.5Q120,657 120,640Q120,623 131.5,611.5Q143,600 160,600Q177,600 188.5,611.5Q200,623 200,640Q200,657 188.5,668.5Q177,680 160,680Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val menuBook: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "MenuBook",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466L280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Q280,466 280,466Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val navigateBefore: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "NavigateBefore",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,720L320,480L560,240L616,296L432,480L616,664L560,720Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val navigateNext: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "NavigateNext",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val openInNew: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "OpenInNew",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L480,120L480,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,480L840,480L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM388,628L332,572L704,200L560,200L560,120L840,120L840,400L760,400L760,256L388,628Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val redo: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Redo",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M396,760Q299,760 229.5,697Q160,634 160,540Q160,446 229.5,383Q299,320 396,320L648,320L544,216L600,160L800,360L600,560L544,504L648,400L396,400Q333,400 286.5,440Q240,480 240,540Q240,600 286.5,640Q333,680 396,680L680,680L680,760L396,760Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val sort: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Sort",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M120,720L120,640L360,640L360,720L120,720ZM120,520L120,440L600,440L600,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val undo: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Undo",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val volumeUp: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "VolumeUp",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = true,
|
||||
paths = listOf(
|
||||
"""M560,829L560,747Q650,721 705,647Q760,573 760,479Q760,385 705,311Q650,237 560,211L560,129Q684,157 762,254.5Q840,352 840,479Q840,606 762,703.5Q684,801 560,829ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,318Q607,340 633.5,384Q660,428 660,480Q660,531 633.5,574.5Q607,618 560,640ZM400,354L314,440L200,440L200,520L314,520L400,606L400,354ZM300,480L300,480L300,480L300,480L300,480L300,480Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun materialIcon(
|
||||
name: String,
|
||||
defaultWidth: Float,
|
||||
defaultHeight: Float,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float,
|
||||
autoMirror: Boolean,
|
||||
paths: List<String>
|
||||
): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = name,
|
||||
defaultWidth = defaultWidth.dp,
|
||||
defaultHeight = defaultHeight.dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
autoMirror = autoMirror
|
||||
).apply {
|
||||
paths.forEach { pathData ->
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(pathData).toNodes(),
|
||||
fill = SolidColor(Color.Black)
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,159 @@
|
|||
@file:Suppress("ObjectPropertyName", "unused")
|
||||
|
||||
package androidx.compose.material.icons.outlined
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Icons.Outlined.AccountCircle: ImageVector
|
||||
get() = EpistemeOutlinedIcons.accountCircle
|
||||
|
||||
val Icons.Outlined.Email: ImageVector
|
||||
get() = EpistemeOutlinedIcons.email
|
||||
|
||||
val Icons.Outlined.FavoriteBorder: ImageVector
|
||||
get() = EpistemeOutlinedIcons.favoriteBorder
|
||||
|
||||
val Icons.Outlined.Feedback: ImageVector
|
||||
get() = EpistemeOutlinedIcons.feedback
|
||||
|
||||
val Icons.Outlined.FileOpen: ImageVector
|
||||
get() = EpistemeOutlinedIcons.fileOpen
|
||||
|
||||
val Icons.Outlined.Gavel: ImageVector
|
||||
get() = EpistemeOutlinedIcons.gavel
|
||||
|
||||
val Icons.Outlined.Policy: ImageVector
|
||||
get() = EpistemeOutlinedIcons.policy
|
||||
|
||||
private object EpistemeOutlinedIcons {
|
||||
val accountCircle: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "AccountCircle",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val email: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Email",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val favoriteBorder: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "FavoriteBorder",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,840L422,788Q321,697 255,631Q189,565 150,512.5Q111,460 95.5,416Q80,372 80,326Q80,232 143,169Q206,106 300,106Q352,106 399,128Q446,150 480,190Q514,150 561,128Q608,106 660,106Q754,106 817,169Q880,232 880,326Q880,372 864.5,416Q849,460 810,512.5Q771,565 705,631Q639,697 538,788L480,840ZM480,732Q576,646 638,584.5Q700,523 736,477.5Q772,432 786,396.5Q800,361 800,326Q800,266 760,226Q720,186 660,186Q613,186 573,212.5Q533,239 518,280L518,280L442,280L442,280Q427,239 387,212.5Q347,186 300,186Q240,186 200,226Q160,266 160,326Q160,361 174,396.5Q188,432 224,477.5Q260,523 322,584.5Q384,646 480,732ZM480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459L480,459L480,459L480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Q480,459 480,459Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val feedback: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Feedback",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val fileOpen: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "FileOpen",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val gavel: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Gavel",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val policy: ImageVector by lazy {
|
||||
materialIcon(
|
||||
name = "Policy",
|
||||
defaultWidth = 24f,
|
||||
defaultHeight = 24f,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f,
|
||||
autoMirror = false,
|
||||
paths = listOf(
|
||||
"""M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560Q513,560 536.5,536.5ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z"""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun materialIcon(
|
||||
name: String,
|
||||
defaultWidth: Float,
|
||||
defaultHeight: Float,
|
||||
viewportWidth: Float,
|
||||
viewportHeight: Float,
|
||||
autoMirror: Boolean,
|
||||
paths: List<String>
|
||||
): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = name,
|
||||
defaultWidth = defaultWidth.dp,
|
||||
defaultHeight = defaultHeight.dp,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
autoMirror = autoMirror
|
||||
).apply {
|
||||
paths.forEach { pathData ->
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(pathData).toNodes(),
|
||||
fill = SolidColor(Color.Black)
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -78,7 +78,20 @@ data class BlockStyle(
|
|||
@ProtoNumber(31) @Serializable(with = DpSerializer::class) val borderTopRightRadius: Dp = 0.dp,
|
||||
@ProtoNumber(32) @Serializable(with = DpSerializer::class) val borderBottomRightRadius: Dp = 0.dp,
|
||||
@ProtoNumber(33) @Serializable(with = DpSerializer::class) val borderBottomLeftRadius: Dp = 0.dp,
|
||||
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp
|
||||
@ProtoNumber(34) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp,
|
||||
@ProtoNumber(35) @Serializable(with = DpSerializer::class) val minWidth: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(36) @Serializable(with = DpSerializer::class) val minHeight: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(37) @Serializable(with = DpSerializer::class) val maxHeight: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(38) val overflow: String? = null,
|
||||
@ProtoNumber(39) val breakBefore: String? = null,
|
||||
@ProtoNumber(40) val breakAfter: String? = null,
|
||||
@ProtoNumber(41) val breakInside: String? = null,
|
||||
@ProtoNumber(42) val widows: Int = 2,
|
||||
@ProtoNumber(43) val orphans: Int = 2,
|
||||
@ProtoNumber(44) val visibility: String? = null,
|
||||
@ProtoNumber(45) val objectFit: String? = null,
|
||||
@ProtoNumber(46) val objectPosition: String? = null,
|
||||
@ProtoNumber(47) val backgroundImage: String? = null
|
||||
) {
|
||||
fun merge(other: BlockStyle): BlockStyle {
|
||||
return BlockStyle(
|
||||
|
|
@ -125,7 +138,20 @@ data class BlockStyle(
|
|||
horizontalAlign = other.horizontalAlign ?: this.horizontalAlign,
|
||||
filter = other.filter ?: this.filter,
|
||||
borderCollapse = other.borderCollapse ?: this.borderCollapse,
|
||||
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing
|
||||
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing,
|
||||
minWidth = if (other.minWidth.isSpecified) other.minWidth else this.minWidth,
|
||||
minHeight = if (other.minHeight.isSpecified) other.minHeight else this.minHeight,
|
||||
maxHeight = if (other.maxHeight.isSpecified) other.maxHeight else this.maxHeight,
|
||||
overflow = other.overflow ?: this.overflow,
|
||||
breakBefore = other.breakBefore ?: this.breakBefore,
|
||||
breakAfter = other.breakAfter ?: this.breakAfter,
|
||||
breakInside = other.breakInside ?: this.breakInside,
|
||||
widows = if (other.widows != 2) other.widows else this.widows,
|
||||
orphans = if (other.orphans != 2) other.orphans else this.orphans,
|
||||
visibility = other.visibility ?: this.visibility,
|
||||
objectFit = other.objectFit ?: this.objectFit,
|
||||
objectPosition = other.objectPosition ?: this.objectPosition,
|
||||
backgroundImage = other.backgroundImage ?: this.backgroundImage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -307,7 +333,10 @@ data class CssStyle(
|
|||
@ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified,
|
||||
@ProtoNumber(14) val textDecorationStyle: String? = null,
|
||||
@ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified,
|
||||
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified
|
||||
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified,
|
||||
@ProtoNumber(17) val whiteSpace: String? = null,
|
||||
@ProtoNumber(18) val verticalAlign: String? = null,
|
||||
@ProtoNumber(19) val customProperties: Map<String, String> = emptyMap()
|
||||
) {
|
||||
fun merge(other: CssStyle): CssStyle {
|
||||
return CssStyle(
|
||||
|
|
@ -326,7 +355,10 @@ data class CssStyle(
|
|||
wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing,
|
||||
textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle,
|
||||
textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor,
|
||||
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset
|
||||
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset,
|
||||
whiteSpace = other.whiteSpace ?: this.whiteSpace,
|
||||
verticalAlign = other.verticalAlign ?: this.verticalAlign,
|
||||
customProperties = this.customProperties + other.customProperties
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -340,7 +372,9 @@ data class CssSelector(
|
|||
@Serializable
|
||||
data class CssRule(
|
||||
@ProtoNumber(1) val selector: CssSelector,
|
||||
@ProtoNumber(2) val style: CssStyle
|
||||
@ProtoNumber(2) val style: CssStyle,
|
||||
@ProtoNumber(3) val pseudoElement: String? = null,
|
||||
@ProtoNumber(4) val sourceOrder: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -371,7 +405,8 @@ data class OptimizedCssRules(
|
|||
@ProtoNumber(1) val byTag: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(2) val byClass: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(3) val byId: Map<String, List<CssRule>> = emptyMap(),
|
||||
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList()
|
||||
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList(),
|
||||
@ProtoNumber(5) val allRules: List<CssRule> = emptyList()
|
||||
) {
|
||||
fun merge(other: OptimizedCssRules): OptimizedCssRules {
|
||||
fun mergeMap(
|
||||
|
|
@ -397,16 +432,17 @@ data class OptimizedCssRules(
|
|||
byTag = mergeMap(this.byTag, other.byTag),
|
||||
byClass = mergeMap(this.byClass, other.byClass),
|
||||
byId = mergeMap(this.byId, other.byId),
|
||||
otherComplex = this.otherComplex + other.otherComplex
|
||||
otherComplex = this.otherComplex + other.otherComplex,
|
||||
allRules = this.toFlatList() + other.toFlatList()
|
||||
)
|
||||
}
|
||||
|
||||
fun toFlatList(): List<CssRule> {
|
||||
return byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex
|
||||
return allRules.ifEmpty { byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex }
|
||||
}
|
||||
}
|
||||
|
||||
data class OptimizedCssParseResult(
|
||||
val rules: OptimizedCssRules,
|
||||
val fontFaces: List<FontFaceInfo>
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ sealed interface AppAction {
|
|||
data class AppFontPreferenceChanged(val preference: AppFontPreference) : AppAction
|
||||
data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction
|
||||
data class CustomAppThemeDeleted(val themeId: String) : AppAction
|
||||
data class CustomReaderThemesChanged(val themes: List<ReaderTheme>) : AppAction
|
||||
data class SyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class FolderSyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
data class TabsEnabledChanged(val enabled: Boolean) : AppAction
|
||||
|
|
|
|||
|
|
@ -272,6 +272,7 @@ data class SharedReaderScreenState(
|
|||
val appSeedColor: Color? = null,
|
||||
val appFontPreference: AppFontPreference = AppFontPreference.System,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val customReaderThemes: List<ReaderTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val allTags: List<Tag> = emptyList(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
enum class SharedCloudBookMetadataWinner {
|
||||
LOCAL,
|
||||
REMOTE,
|
||||
SAME
|
||||
}
|
||||
|
||||
fun sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long
|
||||
): SharedCloudBookMetadataWinner {
|
||||
val localTimestamp = localModifiedTimestamp ?: Long.MIN_VALUE
|
||||
return when {
|
||||
localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL
|
||||
remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE
|
||||
else -> SharedCloudBookMetadataWinner.SAME
|
||||
}
|
||||
}
|
||||
|
||||
fun sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): SharedCloudBookMetadataWinner {
|
||||
val localTimestamp = maxOf(localModifiedTimestamp ?: Long.MIN_VALUE, localSidecarModifiedTimestamp)
|
||||
return when {
|
||||
localTimestamp > remoteModifiedTimestamp -> SharedCloudBookMetadataWinner.LOCAL
|
||||
remoteModifiedTimestamp > localTimestamp -> SharedCloudBookMetadataWinner.REMOTE
|
||||
else -> SharedCloudBookMetadataWinner.SAME
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldApplyRemoteCloudBookMetadataUpdate(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long
|
||||
): Boolean {
|
||||
return sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.REMOTE
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookMetadataUpdate(
|
||||
localModifiedTimestamp: Long,
|
||||
remoteModifiedTimestamp: Long
|
||||
): Boolean {
|
||||
return sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.LOCAL
|
||||
}
|
||||
|
||||
fun shouldApplyRemoteCloudBookUpdate(
|
||||
localModifiedTimestamp: Long?,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): Boolean {
|
||||
return sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp,
|
||||
localSidecarModifiedTimestamp = localSidecarModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.REMOTE
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookUpdate(
|
||||
localModifiedTimestamp: Long,
|
||||
remoteModifiedTimestamp: Long,
|
||||
localSidecarModifiedTimestamp: Long = 0L
|
||||
): Boolean {
|
||||
return sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = localModifiedTimestamp,
|
||||
remoteModifiedTimestamp = remoteModifiedTimestamp,
|
||||
localSidecarModifiedTimestamp = localSidecarModifiedTimestamp
|
||||
) == SharedCloudBookMetadataWinner.LOCAL
|
||||
}
|
||||
|
||||
fun shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable: Boolean,
|
||||
localContentModifiedTimestamp: Long,
|
||||
remoteContentModifiedTimestamp: Long,
|
||||
remoteDeleted: Boolean = false
|
||||
): Boolean {
|
||||
return !remoteDeleted &&
|
||||
remoteContentModifiedTimestamp > 0L &&
|
||||
(!localFileAvailable || remoteContentModifiedTimestamp > localContentModifiedTimestamp)
|
||||
}
|
||||
|
||||
fun shouldUploadLocalCloudBookContent(
|
||||
localFileAvailable: Boolean,
|
||||
localContentModifiedTimestamp: Long,
|
||||
remoteContentModifiedTimestamp: Long?
|
||||
): Boolean {
|
||||
return localFileAvailable &&
|
||||
localContentModifiedTimestamp > 0L &&
|
||||
localContentModifiedTimestamp > (remoteContentModifiedTimestamp ?: 0L)
|
||||
}
|
||||
|
||||
fun sharedCloudBookContentFileName(bookId: String, type: FileType): String? {
|
||||
val extension = SharedFileCapabilities.primaryExtensionFor(type) ?: return null
|
||||
return "$bookId.$extension"
|
||||
}
|
||||
|
|
@ -89,6 +89,10 @@ object SharedFileCapabilities {
|
|||
"application/x-rar-compressed",
|
||||
"application/x-cb7",
|
||||
"application/x-7z-compressed",
|
||||
"application/vnd.comicbook+tar",
|
||||
"application/x-cbt",
|
||||
"application/x-tar",
|
||||
"application/tar",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
|
|
@ -166,6 +170,13 @@ object SharedFileCapabilities {
|
|||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.CBT,
|
||||
displayName = "CBT",
|
||||
extensions = setOf("cbt"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
),
|
||||
FileTypeCapability(
|
||||
type = FileType.DOCX,
|
||||
displayName = "DOCX",
|
||||
|
|
@ -211,11 +222,13 @@ object SharedFileCapabilities {
|
|||
FileType.CBZ to "application/zip",
|
||||
FileType.CBR to "application/zip",
|
||||
FileType.CB7 to "application/zip",
|
||||
FileType.CBT to "application/x-tar",
|
||||
FileType.DOCX to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
FileType.PPTX to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
FileType.ODT to "application/vnd.oasis.opendocument.text",
|
||||
FileType.FODT to "application/x-vnd.oasis.opendocument.text-flat-xml"
|
||||
)
|
||||
val comicArchiveTypes: Set<FileType> = setOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT)
|
||||
val knownFileTypes: Set<FileType> = all.mapTo(mutableSetOf()) { it.type }
|
||||
|
||||
fun capabilityFor(type: FileType): FileTypeCapability? {
|
||||
|
|
@ -234,6 +247,10 @@ object SharedFileCapabilities {
|
|||
return mimeTypesByType[type]
|
||||
}
|
||||
|
||||
fun isComicArchive(type: FileType): Boolean {
|
||||
return type in comicArchiveTypes
|
||||
}
|
||||
|
||||
fun fileTypeForName(fileName: String): FileType {
|
||||
return resolveFileTypeForName(fileName) ?: FileType.UNKNOWN
|
||||
}
|
||||
|
|
@ -267,6 +284,9 @@ object SharedFileCapabilities {
|
|||
"application/x-cb7", "application/x-7z-compressed" -> {
|
||||
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
|
||||
}
|
||||
"application/vnd.comicbook+tar", "application/x-cbt", "application/x-tar", "application/tar" -> {
|
||||
if (fileName?.endsWith(".cbt", ignoreCase = true) == true) FileType.CBT else null
|
||||
}
|
||||
"application/pdf" -> FileType.PDF
|
||||
"application/epub+zip" -> FileType.EPUB
|
||||
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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, PPTX, UNKNOWN
|
||||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, CBT, DOCX, ODT, FODT, PPTX, UNKNOWN
|
||||
}
|
||||
|
||||
val PDF_VIEWER_FILE_TYPES: Set<FileType>
|
||||
|
|
@ -67,7 +67,8 @@ data class SyncedFolder(
|
|||
val uriString: String,
|
||||
val name: String,
|
||||
val lastScanTime: Long,
|
||||
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes
|
||||
val allowedFileTypes: Set<FileType> = SharedFileCapabilities.knownFileTypes,
|
||||
val localSyncEnabled: Boolean = true
|
||||
)
|
||||
|
||||
data class BookItem(
|
||||
|
|
@ -99,7 +100,8 @@ data class BookItem(
|
|||
val readerSettings: ReaderSettings? = null,
|
||||
val readerBookmarks: List<ReaderBookmark> = emptyList(),
|
||||
val readerHighlights: List<UserHighlight> = emptyList(),
|
||||
val pdfReaderViewport: SharedPdfReaderViewport? = null
|
||||
val pdfReaderViewport: SharedPdfReaderViewport? = null,
|
||||
val readingPositionModifiedTimestamp: Long = 0L
|
||||
)
|
||||
|
||||
data class Shelf(
|
||||
|
|
|
|||
|
|
@ -99,6 +99,46 @@ object SharedLibraryEditor {
|
|||
)
|
||||
}
|
||||
|
||||
fun createShelfWithBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
name: String,
|
||||
bookIds: Iterable<String>,
|
||||
clearSelection: Boolean = true,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val shelfId = "shelf_$nowMillis"
|
||||
val newRefs = selectedBooks.map { bookId ->
|
||||
BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = if (clearSelection && selectedBooks.isNotEmpty()) emptySet() else state.selectedBookIds,
|
||||
bannerMessage = if (selectedBooks.isEmpty()) {
|
||||
BannerMessage.string(
|
||||
"banner_shelf_created",
|
||||
"Created shelf \"%1\$s\".",
|
||||
trimmed
|
||||
)
|
||||
} else {
|
||||
BannerMessage.quantity(
|
||||
"banner_shelf_created_with_books",
|
||||
selectedBooks.size,
|
||||
"Created shelf \"%1\$s\" with %2\$d book.",
|
||||
"Created shelf \"%1\$s\" with %2\$d books.",
|
||||
trimmed,
|
||||
selectedBooks.size
|
||||
)
|
||||
}
|
||||
),
|
||||
shelfRecords = shelfRecords + ShelfRecord(id = shelfId, name = trimmed),
|
||||
shelfRefs = shelfRefs + newRefs
|
||||
)
|
||||
}
|
||||
|
||||
fun createSmartShelf(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
|
|
@ -239,25 +279,73 @@ object SharedLibraryEditor {
|
|||
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 addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs,
|
||||
bookIds = state.selectedBookIds,
|
||||
shelfIds = listOf(shelfId),
|
||||
clearSelection = true,
|
||||
nowMillis = nowMillis,
|
||||
bannerName = "banner_books_added_to_shelf",
|
||||
singularMessage = "%1\$d book added to shelf.",
|
||||
pluralMessage = "%1\$d books added to shelf."
|
||||
)
|
||||
}
|
||||
|
||||
fun addBooksToShelves(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
bookIds: Iterable<String>,
|
||||
shelfIds: Iterable<String>,
|
||||
clearSelection: Boolean = true,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
return addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs,
|
||||
bookIds = bookIds,
|
||||
shelfIds = shelfIds,
|
||||
clearSelection = clearSelection,
|
||||
nowMillis = nowMillis,
|
||||
bannerName = "banner_books_added_to_shelves",
|
||||
singularMessage = "%1\$d shelf entry added.",
|
||||
pluralMessage = "%1\$d shelf entries added."
|
||||
)
|
||||
}
|
||||
|
||||
fun replaceShelfBooks(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
shelfId: String,
|
||||
bookIds: Iterable<String>,
|
||||
nowMillis: Long = currentTimestamp()
|
||||
): SharedLibraryMutationResult? {
|
||||
val cleanShelfId = shelfId.trim()
|
||||
if (!canMutateShelf(cleanShelfId)) return null
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val shelfName = state.shelves.firstOrNull { it.id == cleanShelfId }?.name
|
||||
?: shelfRecords.firstOrNull { it.id == cleanShelfId }?.name
|
||||
?: cleanShelfId
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
"banner_books_added_to_shelf",
|
||||
additions.size,
|
||||
"%1\$d book added to shelf.",
|
||||
"%1\$d books added to shelf.",
|
||||
additions.size
|
||||
"banner_shelf_books_updated",
|
||||
selectedBooks.size,
|
||||
"Updated \"%1\$s\" with %2\$d book.",
|
||||
"Updated \"%1\$s\" with %2\$d books.",
|
||||
shelfName,
|
||||
selectedBooks.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs + additions
|
||||
shelfRefs = shelfRefs.filterNot { it.shelfId == cleanShelfId } +
|
||||
selectedBooks.map { bookId ->
|
||||
BookShelfRef(bookId = bookId, shelfId = cleanShelfId, addedAt = nowMillis)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -325,6 +413,51 @@ object SharedLibraryEditor {
|
|||
shelfRefs = shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
private fun addBooksToShelves(
|
||||
state: SharedReaderScreenState,
|
||||
shelfRecords: List<ShelfRecord>,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
bookIds: Iterable<String>,
|
||||
shelfIds: Iterable<String>,
|
||||
clearSelection: Boolean,
|
||||
nowMillis: Long,
|
||||
bannerName: String,
|
||||
singularMessage: String,
|
||||
pluralMessage: String
|
||||
): SharedLibraryMutationResult? {
|
||||
val selectedBooks = cleanBookIds(bookIds)
|
||||
val targetShelfIds = shelfIds
|
||||
.map { it.trim() }
|
||||
.filter { canMutateShelf(it) }
|
||||
.distinct()
|
||||
if (selectedBooks.isEmpty() || targetShelfIds.isEmpty()) return null
|
||||
|
||||
val existing = shelfRefs.mapTo(mutableSetOf()) { it.bookId to it.shelfId }
|
||||
val additions = targetShelfIds.flatMap { shelfId ->
|
||||
selectedBooks.mapNotNull { bookId ->
|
||||
if (!existing.add(bookId to shelfId)) {
|
||||
null
|
||||
} else {
|
||||
BookShelfRef(bookId = bookId, shelfId = shelfId, addedAt = nowMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = if (clearSelection) emptySet() else state.selectedBookIds,
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
bannerName,
|
||||
additions.size,
|
||||
singularMessage,
|
||||
pluralMessage,
|
||||
additions.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs + additions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTagList(input: String, knownTags: List<Tag>, nowMillis: Long = currentTimestamp()): List<Tag> {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,13 @@ data class SharedFolderBookMetadata(
|
|||
seriesIndex = existing?.seriesIndex,
|
||||
lastPageIndex = lastPage,
|
||||
readerPosition = parsedReaderPosition ?: existing?.readerPosition,
|
||||
readingPositionModifiedTimestamp = if (
|
||||
parsedReaderPosition != null || lastPage != null || progressPercentage > 0f
|
||||
) {
|
||||
metadataTimestamp
|
||||
} else {
|
||||
existing?.readingPositionModifiedTimestamp ?: 0L
|
||||
},
|
||||
readerBookmarks = parsedBookmarks ?: existing?.readerBookmarks.orEmpty(),
|
||||
readerHighlights = parsedHighlights ?: existing?.readerHighlights.orEmpty()
|
||||
)
|
||||
|
|
@ -152,6 +159,9 @@ data class SharedFolderBookMetadata(
|
|||
chapterIndex = lastChapterIndex,
|
||||
cfi = lastPositionCfi,
|
||||
pageIndex = lastPage
|
||||
).withFallbacks(
|
||||
blockIndex = locatorBlockIndex,
|
||||
charOffset = locatorCharOffset
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +282,15 @@ object LocalFolderSyncEngine {
|
|||
nowMillis: Long = currentTimestamp(),
|
||||
metadataOnly: Boolean = false
|
||||
): LocalFolderSyncResult {
|
||||
if (!folder.localSyncEnabled) {
|
||||
return LocalFolderSyncResult(
|
||||
state = state,
|
||||
idMigrations = emptyMap(),
|
||||
removedBookIds = emptySet(),
|
||||
stats = LocalFolderSyncStats()
|
||||
)
|
||||
}
|
||||
|
||||
val folderRoot = folder.uriString
|
||||
val allowedTypes = folder.allowedFileTypes
|
||||
val booksById = linkedMapOf<String, BookItem>()
|
||||
|
|
@ -439,16 +458,7 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
|||
!bookmarksJson.isNullOrBlank() ||
|
||||
!highlightsJson.isNullOrBlank()
|
||||
if (!isDirty) return null
|
||||
val positionCfi = position?.cfi ?: position?.let { locator ->
|
||||
val chapterIndex = locator.chapterIndex
|
||||
val startOffset = locator.startOffset
|
||||
val endOffset = locator.endOffset ?: startOffset
|
||||
if (chapterIndex != null && startOffset != null && endOffset != null) {
|
||||
"desktop:$chapterIndex:$startOffset:$endOffset"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val positionCfi = position?.toStablePositionCfi()
|
||||
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
|
|
@ -463,8 +473,8 @@ fun BookItem.toSharedFolderBookMetadata(): SharedFolderBookMetadata? {
|
|||
isRecent = isRecent,
|
||||
lastModifiedTimestamp = localFolderModifiedTimestamp(),
|
||||
bookmarksJson = bookmarksJson,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
locatorBlockIndex = position?.blockIndex,
|
||||
locatorCharOffset = position?.charOffset,
|
||||
customName = null,
|
||||
highlightsJson = highlightsJson,
|
||||
seriesName = null,
|
||||
|
|
|
|||
|
|
@ -42,11 +42,15 @@ data class ReaderLocator(
|
|||
val pageIndex: Int? = null,
|
||||
val startOffset: Int? = null,
|
||||
val endOffset: Int? = null,
|
||||
val blockIndex: Int? = null,
|
||||
val charOffset: Int? = null,
|
||||
val textQuote: String? = null,
|
||||
val cfi: String? = null
|
||||
) {
|
||||
val hasTextRange: Boolean
|
||||
get() = startOffset != null && endOffset != null && endOffset >= startOffset
|
||||
val hasBlockPosition: Boolean
|
||||
get() = blockIndex != null && charOffset != null
|
||||
|
||||
fun withFallbacks(
|
||||
chapterIndex: Int? = null,
|
||||
|
|
@ -55,6 +59,8 @@ data class ReaderLocator(
|
|||
pageIndex: Int? = null,
|
||||
startOffset: Int? = null,
|
||||
endOffset: Int? = null,
|
||||
blockIndex: Int? = null,
|
||||
charOffset: Int? = null,
|
||||
textQuote: String? = null,
|
||||
cfi: String? = null
|
||||
): ReaderLocator {
|
||||
|
|
@ -65,6 +71,8 @@ data class ReaderLocator(
|
|||
pageIndex = this.pageIndex ?: pageIndex,
|
||||
startOffset = this.startOffset ?: startOffset,
|
||||
endOffset = this.endOffset ?: endOffset,
|
||||
blockIndex = this.blockIndex ?: blockIndex,
|
||||
charOffset = this.charOffset ?: charOffset,
|
||||
textQuote = this.textQuote ?: textQuote,
|
||||
cfi = this.cfi ?: cfi
|
||||
)
|
||||
|
|
@ -74,6 +82,10 @@ data class ReaderLocator(
|
|||
val sameChapter = chapterIndex == null || other.chapterIndex == null || chapterIndex == other.chapterIndex
|
||||
if (!sameChapter) return false
|
||||
|
||||
if (hasBlockPosition && other.hasBlockPosition) {
|
||||
return blockIndex == other.blockIndex && charOffset == other.charOffset
|
||||
}
|
||||
|
||||
if (hasTextRange && other.hasTextRange) {
|
||||
return startOffset == other.startOffset && endOffset == other.endOffset
|
||||
}
|
||||
|
|
@ -92,21 +104,40 @@ data class ReaderLocator(
|
|||
pageIndex: Int? = null,
|
||||
textQuote: String? = null
|
||||
): ReaderLocator {
|
||||
val desktopParts = cfi
|
||||
val stableCfi = cfi?.toStableReaderPositionCfi()
|
||||
val desktopParts = stableCfi
|
||||
?.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 androidLocatorParts = stableCfi
|
||||
?.takeIf { it.startsWith("android-locator:") }
|
||||
?.split(':')
|
||||
.orEmpty()
|
||||
val parsedAndroidChapterIndex = androidLocatorParts.getOrNull(1)?.toIntOrNull()
|
||||
val parsedBlockIndex = androidLocatorParts.getOrNull(2)?.toIntOrNull()
|
||||
val parsedCharOffset = androidLocatorParts.getOrNull(3)?.toIntOrNull()
|
||||
?.takeIf { it >= 0 }
|
||||
val parsedAndroidEndOffset = parsedCharOffset
|
||||
?.let { start -> textQuote?.takeIf { it.isNotBlank() }?.let { start + it.length } }
|
||||
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 parsedStartOffset = when {
|
||||
hasOffsetRange -> possibleStartOffset
|
||||
parsedBlockIndex != null && parsedAndroidEndOffset != null -> parsedCharOffset
|
||||
else -> null
|
||||
}
|
||||
val parsedEndOffset = when {
|
||||
hasOffsetRange -> possibleEndOffset
|
||||
parsedBlockIndex != null -> parsedAndroidEndOffset
|
||||
else -> null
|
||||
}
|
||||
val parsedPageIndex = when {
|
||||
pageIndex != null -> pageIndex
|
||||
desktopParts.size == 3 || desktopParts.size >= 5 || (desktopParts.size == 4 && !hasOffsetRange) ->
|
||||
|
|
@ -114,23 +145,56 @@ data class ReaderLocator(
|
|||
else -> null
|
||||
}
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex ?: parsedChapterIndex,
|
||||
chapterIndex = chapterIndex ?: parsedChapterIndex ?: parsedAndroidChapterIndex,
|
||||
pageIndex = parsedPageIndex,
|
||||
startOffset = parsedStartOffset,
|
||||
endOffset = parsedEndOffset,
|
||||
blockIndex = parsedBlockIndex,
|
||||
charOffset = parsedCharOffset,
|
||||
textQuote = textQuote,
|
||||
cfi = cfi
|
||||
cfi = stableCfi ?: cfi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toStableReaderPositionCfi(): String {
|
||||
val trimmed = trim()
|
||||
if (!trimmed.startsWith("desktop-scroll:")) return trimmed
|
||||
return trimmed
|
||||
.split(':', limit = 4)
|
||||
.getOrNull(3)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: trimmed
|
||||
}
|
||||
|
||||
fun ReaderLocator.toStablePositionCfi(): String? {
|
||||
cfi
|
||||
?.toStableReaderPositionCfi()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
|
||||
?.let { return it }
|
||||
|
||||
val chapter = chapterIndex
|
||||
val start = startOffset
|
||||
val end = endOffset ?: start
|
||||
return when {
|
||||
chapter != null && blockIndex != null && charOffset != null ->
|
||||
"android-locator:$chapter:$blockIndex:$charOffset"
|
||||
chapter != null && start != null && end != null ->
|
||||
"desktop:$chapter:$start:$end"
|
||||
chapter != null && pageIndex != null ->
|
||||
"desktop:$chapter:$pageIndex"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
val knownColors = colors.filter { it in HighlightColor.entries }
|
||||
return copy(colors = knownColors.takeIf { it.size == PaletteSize } ?: defaultColors)
|
||||
}
|
||||
|
||||
fun contains(color: HighlightColor): Boolean {
|
||||
|
|
@ -147,14 +211,13 @@ data class ReaderHighlightPalette(
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val PaletteSize: Int = 4
|
||||
val defaultColors: List<HighlightColor>
|
||||
get() = listOf(
|
||||
HighlightColor.YELLOW,
|
||||
HighlightColor.GREEN,
|
||||
HighlightColor.BLUE,
|
||||
HighlightColor.RED,
|
||||
HighlightColor.PURPLE,
|
||||
HighlightColor.ORANGE
|
||||
HighlightColor.RED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,6 +238,8 @@ object EpubAnnotationSerializer {
|
|||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
blockIndex?.let { put("blockIndex", JsonPrimitive(it)) }
|
||||
charOffset?.let { put("charOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
|
|
@ -253,6 +255,8 @@ object EpubAnnotationSerializer {
|
|||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
blockIndex = obj.int("blockIndex"),
|
||||
charOffset = obj.int("charOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -101,13 +101,29 @@ data class ReaderTheme(
|
|||
val isCustom: Boolean = false
|
||||
)
|
||||
|
||||
val BuiltInReaderThemes = listOf(
|
||||
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
|
||||
fun List<ReaderTheme>.sanitizeCustomReaderThemes(): List<ReaderTheme> {
|
||||
val seenIds = mutableSetOf<String>()
|
||||
return asReversed()
|
||||
.filter { theme ->
|
||||
theme.isCustom &&
|
||||
theme.id.isNotBlank() &&
|
||||
theme.name.isNotBlank() &&
|
||||
theme.backgroundColor.isSpecified &&
|
||||
theme.textColor.isSpecified &&
|
||||
seenIds.add(theme.id)
|
||||
}
|
||||
.asReversed()
|
||||
}
|
||||
|
||||
private val StandardReaderSolidThemes = listOf(
|
||||
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("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
|
||||
)
|
||||
|
||||
private val StandardReaderTexturedThemes = listOf(
|
||||
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),
|
||||
|
|
@ -116,21 +132,16 @@ val BuiltInReaderThemes = listOf(
|
|||
ReaderTheme("retro_texture", "Retro", Color(0xFFF6ECD8), Color(0xFF2F2118), false, textureId = ReaderTexture.RETRO_INTRO.id)
|
||||
)
|
||||
|
||||
val BuiltInReaderThemes = listOf(
|
||||
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false)
|
||||
) + StandardReaderSolidThemes + StandardReaderTexturedThemes
|
||||
|
||||
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)
|
||||
)
|
||||
ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true)
|
||||
) + StandardReaderSolidThemes + StandardReaderTexturedThemes.map { theme ->
|
||||
theme.copy(id = "pdf_${theme.id}")
|
||||
}
|
||||
|
||||
fun FormatSettings.toReaderSettings(base: ReaderSettings = ReaderSettings()): ReaderSettings {
|
||||
val horizontalMarginPx = (ReaderAppearanceDefaults.marginPx * horizontalMargin).roundToInt()
|
||||
|
|
@ -169,6 +180,59 @@ fun ReaderTheme.toReaderSettings(base: ReaderSettings = ReaderSettings()): Reade
|
|||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.resetReaderFormatSettings(): ReaderSettings {
|
||||
val defaults = ReaderSettings()
|
||||
return copy(
|
||||
fontSize = defaults.fontSize,
|
||||
lineSpacing = defaults.lineSpacing,
|
||||
margin = defaults.margin,
|
||||
horizontalMargin = defaults.horizontalMargin,
|
||||
verticalMargin = defaults.verticalMargin,
|
||||
textAlign = defaults.textAlign,
|
||||
pageWidth = defaults.pageWidth,
|
||||
fontFamily = defaults.fontFamily,
|
||||
paragraphSpacing = defaults.paragraphSpacing,
|
||||
imageScale = defaults.imageScale,
|
||||
customFontPath = defaults.customFontPath
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.withHorizontalReaderMargin(horizontalMarginPx: Int): ReaderSettings {
|
||||
val nextHorizontal = horizontalMarginPx.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
val currentVertical = resolvedVerticalMargin.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
return copy(
|
||||
margin = max(nextHorizontal, currentVertical),
|
||||
horizontalMargin = nextHorizontal,
|
||||
verticalMargin = currentVertical
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.withVerticalReaderMargin(verticalMarginPx: Int): ReaderSettings {
|
||||
val currentHorizontal = resolvedHorizontalMargin.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
val nextVertical = verticalMarginPx.coerceIn(
|
||||
ReaderAppearanceDefaults.minMarginPx,
|
||||
ReaderAppearanceDefaults.maxMarginPx
|
||||
)
|
||||
return copy(
|
||||
margin = max(currentHorizontal, nextVertical),
|
||||
horizontalMargin = currentHorizontal,
|
||||
verticalMargin = nextVertical
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderSettings.shouldShowPageWidthFormatControl(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED
|
||||
}
|
||||
|
||||
fun readerThemeById(themeId: String?): ReaderTheme? {
|
||||
return BuiltInReaderThemes.firstOrNull { it.id == themeId }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ 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
|
||||
import com.aryan.reader.shared.reader.logSharedReaderDiagnostic
|
||||
|
||||
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
|
||||
private const val ReaderTtsStartTraceLogTag = "EpistemeDesktopTtsStartTrace"
|
||||
|
||||
data class ReaderCloudTtsVoice(
|
||||
val id: String,
|
||||
|
|
@ -325,15 +327,42 @@ object ReaderTtsPlanner {
|
|||
val anchor = session.navigationLocator
|
||||
val pageIndex = anchor?.pageIndex ?: session.reader.currentPageIndex
|
||||
val pages = session.reader.pages.dropWhile { it.pageIndex < pageIndex.coerceAtLeast(0) }
|
||||
val syntheticDesktopAnchor = anchor?.isSyntheticDesktopTtsAnchor() == true
|
||||
val chunks = chunksForPages(session.reader.book, pages)
|
||||
val chapterIndex = anchor?.chapterIndex
|
||||
val startOffset = anchor?.startOffset
|
||||
if (chapterIndex == null && startOffset == null) return chunks
|
||||
var nextIndex = 0
|
||||
return chunks.mapNotNull { chunk ->
|
||||
chunk.afterLocator(chapterIndex = chapterIndex, startOffset = startOffset)
|
||||
?.copy(index = nextIndex++)
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_from_here_start pageIndex=$pageIndex pages=${pages.size} chunks=${chunks.size} " +
|
||||
"syntheticDesktop=$syntheticDesktopAnchor " +
|
||||
"anchor=${anchor.readerTtsLocatorSummary()} first=${chunks.firstOrNull().readerTtsChunkSummary()} " +
|
||||
"second=${chunks.getOrNull(1).readerTtsChunkSummary()}"
|
||||
}
|
||||
if (chapterIndex == null && startOffset == null) return chunks
|
||||
val target = anchor?.toTtsChunkTarget()
|
||||
val startChunkIndex = findReaderTtsChunkStartIndex(chunks, target)
|
||||
?: chunks.indexOfFirst { it.isOnOrAfterLocator(chapterIndex, startOffset) }.takeIf { it >= 0 }
|
||||
?: run {
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_from_here_empty reason=no_start_chunk target=${target.readerTtsTargetSummary()} " +
|
||||
"anchor=${anchor.readerTtsLocatorSummary()} chunks=${chunks.size}"
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
val initialChunk = anchor?.let { chunks[startChunkIndex].sliceFromLocator(it) }
|
||||
val sessionChunks = if (initialChunk == null) {
|
||||
chunks.drop(startChunkIndex + 1)
|
||||
} else {
|
||||
chunks.withInitialChunkOverride(startChunkIndex, initialChunk).drop(startChunkIndex)
|
||||
}
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_from_here_result target=${target.readerTtsTargetSummary()} startChunkIndex=$startChunkIndex " +
|
||||
"sourceChunk=${chunks.getOrNull(startChunkIndex).readerTtsChunkSummary()} " +
|
||||
"initialChunk=${initialChunk.readerTtsChunkSummary()} resultChunks=${sessionChunks.size} " +
|
||||
"resultFirst=${sessionChunks.firstOrNull().readerTtsChunkSummary()}"
|
||||
}
|
||||
return sessionChunks
|
||||
.filter { it.text.isNotBlank() }
|
||||
.mapIndexed { index, chunk -> chunk.copy(index = index) }
|
||||
}
|
||||
|
||||
fun chunksForText(
|
||||
|
|
@ -401,28 +430,51 @@ object ReaderTtsPlanner {
|
|||
}
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk.afterLocator(chapterIndex: Int?, startOffset: Int?): ReaderTtsChunk? {
|
||||
private fun ReaderTtsChunk.isOnOrAfterLocator(chapterIndex: Int?, startOffset: Int?): Boolean {
|
||||
if (chapterIndex != null) {
|
||||
if (this.chapterIndex < chapterIndex) return null
|
||||
if (this.chapterIndex > chapterIndex) return this
|
||||
if (this.chapterIndex < chapterIndex) return false
|
||||
if (this.chapterIndex > chapterIndex) return true
|
||||
}
|
||||
val anchorOffset = startOffset ?: return this
|
||||
if (endOffset <= anchorOffset) return null
|
||||
if (anchorOffset <= this.startOffset) return this
|
||||
return trimStartTo(anchorOffset)
|
||||
val anchorOffset = startOffset ?: return true
|
||||
return endOffset > anchorOffset
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk.trimStartTo(sourceOffset: Int): ReaderTtsChunk? {
|
||||
val boundedOffset = sourceOffset.coerceIn(startOffset, endOffset)
|
||||
if (boundedOffset <= startOffset) return this
|
||||
if (boundedOffset >= endOffset) return null
|
||||
val rawDrop = (boundedOffset - startOffset).coerceIn(0, text.length)
|
||||
val remaining = text.drop(rawDrop)
|
||||
private fun ReaderTtsChunk.sliceFromLocator(locator: ReaderLocator): ReaderTtsChunk? {
|
||||
if (locator.chapterIndex != null && locator.chapterIndex != chapterIndex) return this
|
||||
val sourceOffset = locator.startOffset ?: return this
|
||||
val rawDrop = (sourceOffset - startOffset).coerceIn(0, text.length)
|
||||
val drop = rawDrop
|
||||
if (drop <= 0) {
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_slice_keep reason=drop_at_start rawDrop=$rawDrop " +
|
||||
"locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
|
||||
}
|
||||
return this
|
||||
}
|
||||
if (drop >= text.length) {
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_slice_skip reason=drop_past_end rawDrop=$rawDrop " +
|
||||
"chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
|
||||
}
|
||||
return null
|
||||
}
|
||||
val remaining = text.drop(drop)
|
||||
val leadingWhitespace = remaining.indexOfFirst { !it.isWhitespace() }
|
||||
if (leadingWhitespace < 0) return null
|
||||
if (leadingWhitespace < 0) {
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_slice_skip reason=blank_after_drop rawDrop=$rawDrop " +
|
||||
"chosenDrop=$drop locator=${locator.readerTtsLocatorSummary()} chunk=${readerTtsChunkSummary()}"
|
||||
}
|
||||
return null
|
||||
}
|
||||
val nextText = remaining.drop(leadingWhitespace)
|
||||
if (nextText.isBlank()) return null
|
||||
val nextStartOffset = (boundedOffset + leadingWhitespace).coerceAtMost(endOffset)
|
||||
val nextStartOffset = (sourceOffset + leadingWhitespace).coerceAtMost(endOffset)
|
||||
logReaderTtsStartTrace {
|
||||
"event=planner_slice_result rawDrop=$rawDrop chosenDrop=$drop " +
|
||||
"leadingWhitespace=$leadingWhitespace nextStart=$nextStartOffset locator=${locator.readerTtsLocatorSummary()} " +
|
||||
"chunk=${readerTtsChunkSummary()} nextText=\"${nextText.readerTtsLogPreview()}\""
|
||||
}
|
||||
return copy(
|
||||
text = nextText,
|
||||
spokenText = nextText,
|
||||
|
|
@ -545,7 +597,7 @@ object ReaderTtsPlanner {
|
|||
chunks += ReaderTtsTextRange(
|
||||
text = currentText.toString(),
|
||||
start = currentStart,
|
||||
end = currentStart + currentText.length
|
||||
end = currentEnd
|
||||
)
|
||||
}
|
||||
currentText = StringBuilder()
|
||||
|
|
@ -554,19 +606,25 @@ object ReaderTtsPlanner {
|
|||
}
|
||||
|
||||
for (sentence in sentenceRanges) {
|
||||
val appendText = if (currentText.isEmpty()) {
|
||||
sentence.text
|
||||
} else {
|
||||
val gapStart = (currentEnd - sourceStart).coerceIn(0, source.length)
|
||||
val gapEnd = (sentence.end - sourceStart).coerceIn(gapStart, source.length)
|
||||
source.substring(gapStart, gapEnd)
|
||||
}
|
||||
if (sentence.text.length > maxLength) {
|
||||
flushCurrent()
|
||||
chunks += sentence
|
||||
continue
|
||||
}
|
||||
if (currentText.isNotEmpty() && currentText.length + sentence.text.length + 1 > maxLength) {
|
||||
if (currentText.isNotEmpty() && currentText.length + appendText.length > maxLength) {
|
||||
flushCurrent()
|
||||
currentText.append(sentence.text)
|
||||
currentStart = sentence.start
|
||||
currentEnd = sentence.end
|
||||
} else {
|
||||
if (currentText.isNotEmpty()) currentText.append(" ")
|
||||
currentText.append(sentence.text)
|
||||
currentText.append(appendText)
|
||||
if (currentStart < 0) currentStart = sentence.start
|
||||
currentEnd = sentence.end
|
||||
}
|
||||
|
|
@ -612,6 +670,162 @@ object ReaderTtsPlanner {
|
|||
val start: Int,
|
||||
val end: Int
|
||||
)
|
||||
|
||||
private data class ReaderTtsChunkTarget(
|
||||
val text: String,
|
||||
val sourceCfi: String?,
|
||||
val startOffset: Int
|
||||
)
|
||||
|
||||
private fun ReaderLocator.toTtsChunkTarget(): ReaderTtsChunkTarget? {
|
||||
val offset = startOffset ?: return null
|
||||
val sourceCfi = cfi
|
||||
?.readerTtsSourceCfiBase()
|
||||
?.takeIf { it.startsWith("/") }
|
||||
return ReaderTtsChunkTarget(
|
||||
text = textQuote.orEmpty(),
|
||||
sourceCfi = sourceCfi,
|
||||
startOffset = offset
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderLocator.isSyntheticDesktopTtsAnchor(): Boolean {
|
||||
val value = cfi.orEmpty()
|
||||
return value.startsWith("desktop:") ||
|
||||
value.startsWith("desktop-scroll:") ||
|
||||
value.startsWith("desktop-scroll-page:")
|
||||
}
|
||||
|
||||
private fun findReaderTtsChunkStartIndex(
|
||||
chunks: List<ReaderTtsChunk>,
|
||||
target: ReaderTtsChunkTarget?
|
||||
): Int? {
|
||||
if (target == null) return null
|
||||
|
||||
val exactIndex = chunks.indexOfFirst {
|
||||
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
it.startOffset == target.startOffset &&
|
||||
it.text.normalizedReaderTtsText() == target.text.normalizedReaderTtsText()
|
||||
}
|
||||
if (exactIndex >= 0) return exactIndex
|
||||
|
||||
val sourceAndOffsetIndex = chunks.indexOfFirst {
|
||||
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
target.startOffset >= it.startOffset &&
|
||||
target.startOffset < it.endOffset
|
||||
}
|
||||
if (sourceAndOffsetIndex >= 0) return sourceAndOffsetIndex
|
||||
|
||||
val sourceAndTextIndex = chunks.indexOfFirst {
|
||||
readerSameTtsChunkSource(it.sourceCfi, target.sourceCfi) &&
|
||||
readerTtsTextMatches(it.text, target.text)
|
||||
}
|
||||
if (sourceAndTextIndex >= 0) return sourceAndTextIndex
|
||||
|
||||
val sourceNearestOffsetIndex = chunks
|
||||
.mapIndexedNotNull { index, chunk ->
|
||||
if (readerSameTtsChunkSource(chunk.sourceCfi, target.sourceCfi)) {
|
||||
index to kotlin.math.abs(chunk.startOffset - target.startOffset)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.minByOrNull { it.second }
|
||||
?.first
|
||||
if (sourceNearestOffsetIndex != null) return sourceNearestOffsetIndex
|
||||
|
||||
return findUniqueReaderTtsTextMatch(chunks, target.text)
|
||||
}
|
||||
|
||||
private fun List<ReaderTtsChunk>.withInitialChunkOverride(
|
||||
startChunkIndex: Int,
|
||||
initialChunk: ReaderTtsChunk?
|
||||
): List<ReaderTtsChunk> {
|
||||
if (initialChunk == null || startChunkIndex !in indices) return this
|
||||
val existing = this[startChunkIndex]
|
||||
if (
|
||||
existing.text == initialChunk.text &&
|
||||
existing.sourceCfi == initialChunk.sourceCfi &&
|
||||
existing.startOffset == initialChunk.startOffset
|
||||
) {
|
||||
return this
|
||||
}
|
||||
return toMutableList().also { it[startChunkIndex] = initialChunk }
|
||||
}
|
||||
|
||||
private fun readerSameTtsChunkSource(first: String?, second: String?): Boolean {
|
||||
val firstSource = first.orEmpty()
|
||||
val secondSource = second.orEmpty()
|
||||
if (firstSource.isBlank() || secondSource.isBlank()) return firstSource == secondSource
|
||||
val firstPath = firstSource.readerTtsSourceCfiBase()
|
||||
val secondPath = secondSource.readerTtsSourceCfiBase()
|
||||
return firstPath == secondPath ||
|
||||
readerTtsCfiPathContains(firstPath, secondPath) ||
|
||||
readerTtsCfiPathContains(secondPath, firstPath)
|
||||
}
|
||||
|
||||
private fun readerTtsCfiPathContains(parentPath: String, childPath: String): Boolean {
|
||||
if (parentPath.isBlank() || childPath.isBlank() || parentPath == childPath) return false
|
||||
val parentParts = parentPath.split('/').filter { it.isNotEmpty() }
|
||||
val childParts = childPath.split('/').filter { it.isNotEmpty() }
|
||||
return parentParts.size < childParts.size && childParts.take(parentParts.size) == parentParts
|
||||
}
|
||||
|
||||
private fun readerTtsTextMatches(first: String, second: String): Boolean {
|
||||
val firstNormalized = first.normalizedReaderTtsText()
|
||||
val secondNormalized = second.normalizedReaderTtsText()
|
||||
if (firstNormalized.isBlank() || secondNormalized.isBlank()) return false
|
||||
return firstNormalized == secondNormalized ||
|
||||
firstNormalized.startsWith(secondNormalized) ||
|
||||
secondNormalized.startsWith(firstNormalized)
|
||||
}
|
||||
|
||||
private fun findUniqueReaderTtsTextMatch(chunks: List<ReaderTtsChunk>, text: String): Int? {
|
||||
val matches = chunks.mapIndexedNotNull { index, chunk ->
|
||||
index.takeIf { readerTtsTextMatches(chunk.text, text) }
|
||||
}
|
||||
return matches.singleOrNull()
|
||||
}
|
||||
|
||||
private fun String.readerTtsSourceCfiBase(): String {
|
||||
return substringBefore('|').substringBefore(':')
|
||||
}
|
||||
|
||||
private fun String.normalizedReaderTtsText(): String {
|
||||
return replace(Regex("\\s+"), " ").trim()
|
||||
}
|
||||
|
||||
private inline fun logReaderTtsStartTrace(message: () -> String) {
|
||||
logSharedReaderDiagnostic(ReaderTtsStartTraceLogTag, message)
|
||||
}
|
||||
|
||||
private fun ReaderLocator?.readerTtsLocatorSummary(maxTextLength: Int = 120): String {
|
||||
if (this == null) return "null"
|
||||
return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " +
|
||||
"offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " +
|
||||
"block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " +
|
||||
"cfi=\"${cfi.orEmpty().readerTtsLogPreview(180)}\" text=\"${textQuote.orEmpty().readerTtsLogPreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunk?.readerTtsChunkSummary(maxTextLength: Int = 120): String {
|
||||
if (this == null) return "null"
|
||||
return "index=$index page=$pageIndex chapter=$chapterIndex offsets=$startOffset..$endOffset " +
|
||||
"sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" textChars=${text.length} " +
|
||||
"text=\"${text.readerTtsLogPreview(maxTextLength)}\" spoken=\"${spokenText.readerTtsLogPreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
private fun ReaderTtsChunkTarget?.readerTtsTargetSummary(maxTextLength: Int = 120): String {
|
||||
if (this == null) return "null"
|
||||
return "offset=$startOffset sourceCfi=\"${sourceCfi.orEmpty().readerTtsLogPreview(160)}\" " +
|
||||
"text=\"${text.readerTtsLogPreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
private fun String.readerTtsLogPreview(maxLength: Int = 120): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderCloudTtsState(
|
||||
|
|
@ -625,6 +839,31 @@ data class ReaderCloudTtsState(
|
|||
val cacheSummary: ReaderTtsCacheSummary = ReaderTtsCacheSummary()
|
||||
)
|
||||
|
||||
data class ReaderCloudTtsControlsModel(
|
||||
val isVisible: Boolean,
|
||||
val canPauseResume: Boolean,
|
||||
val canSkipPrevious: Boolean,
|
||||
val canSkipNext: Boolean,
|
||||
val canLocateCurrentChunk: Boolean
|
||||
)
|
||||
|
||||
fun readerCloudTtsControlsModel(cloudTts: ReaderCloudTtsState): ReaderCloudTtsControlsModel {
|
||||
val progress = cloudTts.progress
|
||||
val visible = cloudTts.isLoading || cloudTts.isPlaying || cloudTts.isPaused
|
||||
val hasCurrentChunk = progress.currentChunk != null
|
||||
return ReaderCloudTtsControlsModel(
|
||||
isVisible = visible,
|
||||
canPauseResume = cloudTts.isPlaying || cloudTts.isPaused,
|
||||
canSkipPrevious = !cloudTts.isLoading &&
|
||||
progress.currentChunkIndex > 0 &&
|
||||
progress.chunks.isNotEmpty(),
|
||||
canSkipNext = !cloudTts.isLoading &&
|
||||
progress.currentChunkIndex >= 0 &&
|
||||
progress.currentChunkIndex < progress.chunks.lastIndex,
|
||||
canLocateCurrentChunk = hasCurrentChunk
|
||||
)
|
||||
}
|
||||
|
||||
data class ReaderAiResultState(
|
||||
val title: String? = null,
|
||||
val text: String = "",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ private val DefaultReaderBottomToolIds: Set<String>
|
|||
ReaderTool.SLIDER.id,
|
||||
ReaderTool.TOC.id,
|
||||
ReaderTool.FORMAT.id,
|
||||
ReaderTool.SEARCH.id,
|
||||
ReaderTool.AI_FEATURES.id,
|
||||
ReaderTool.TTS_CONTROLS.id
|
||||
ReaderTool.SEARCH.id
|
||||
)
|
||||
|
||||
enum class ReaderTool(
|
||||
|
|
@ -22,8 +20,8 @@ enum class ReaderTool(
|
|||
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),
|
||||
AI_FEATURES("ai_features", "AI Features", "Bottom Bar"),
|
||||
TTS_CONTROLS("tts_controls", "TTS Controls", "Bottom Bar"),
|
||||
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"),
|
||||
|
|
@ -31,7 +29,6 @@ enum class ReaderTool(
|
|||
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");
|
||||
|
||||
|
|
|
|||
|
|
@ -90,22 +90,11 @@ data class ReaderTtsReplacementApplyResult(
|
|||
|
||||
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.",
|
||||
)
|
||||
},
|
||||
)
|
||||
val validation = ReaderWordReplacementEngine.validate(rule.toWordReplacementRule())
|
||||
return ReaderTtsReplacementValidation(
|
||||
isValid = validation.isValid,
|
||||
message = validation.message,
|
||||
)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
|
|
@ -117,52 +106,31 @@ object ReaderTtsReplacementEngine {
|
|||
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
|
||||
}
|
||||
}
|
||||
val result = ReaderWordReplacementEngine.apply(
|
||||
text = text,
|
||||
rules = preferences.activeRulesForBook(bookId).map { it.toWordReplacementRule() },
|
||||
)
|
||||
|
||||
return ReaderTtsReplacementApplyResult(
|
||||
text = current,
|
||||
appliedRuleIds = applied,
|
||||
errors = errors,
|
||||
text = result.text,
|
||||
appliedRuleIds = result.appliedRuleIds,
|
||||
errors = result.errors.map {
|
||||
ReaderTtsReplacementError(ruleId = it.ruleId, message = it.message)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
private fun ReaderTtsReplacementRule.toWordReplacementRule(): ReaderWordReplacementRule {
|
||||
return ReaderWordReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = to,
|
||||
enabled = enabled,
|
||||
isRegex = isRegex,
|
||||
matchCase = matchCase,
|
||||
wholeWord = wholeWord,
|
||||
)
|
||||
}
|
||||
|
||||
fun ReaderTtsChunk.withTtsReplacements(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class ReaderWordReplacementRule(
|
||||
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 ReaderWordReplacementValidation(
|
||||
val isValid: Boolean,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
data class ReaderWordReplacementError(
|
||||
val ruleId: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
data class ReaderWordReplacementApplyResult(
|
||||
val text: String,
|
||||
val appliedRuleIds: List<String> = emptyList(),
|
||||
val errors: List<ReaderWordReplacementError> = emptyList(),
|
||||
)
|
||||
|
||||
object ReaderWordReplacementEngine {
|
||||
fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation {
|
||||
if (rule.from.isBlank()) {
|
||||
return ReaderWordReplacementValidation(isValid = false, message = "Enter text to replace.")
|
||||
}
|
||||
if (!rule.isRegex) {
|
||||
return ReaderWordReplacementValidation(isValid = true)
|
||||
}
|
||||
return runCatching { rule.toRegex() }
|
||||
.fold(
|
||||
onSuccess = { ReaderWordReplacementValidation(isValid = true) },
|
||||
onFailure = {
|
||||
ReaderWordReplacementValidation(
|
||||
isValid = false,
|
||||
message = it.message ?: "This regex is not valid.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
rules: List<ReaderWordReplacementRule>,
|
||||
): ReaderWordReplacementApplyResult {
|
||||
if (text.isEmpty() || rules.isEmpty()) {
|
||||
return ReaderWordReplacementApplyResult(text = text)
|
||||
}
|
||||
|
||||
var current = text
|
||||
val applied = mutableListOf<String>()
|
||||
val errors = mutableListOf<ReaderWordReplacementError>()
|
||||
|
||||
rules.forEach { rule ->
|
||||
if (!rule.enabled || rule.from.isBlank()) return@forEach
|
||||
val regex = runCatching { rule.toRegex() }
|
||||
.onFailure {
|
||||
errors += ReaderWordReplacementError(
|
||||
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 += ReaderWordReplacementError(
|
||||
ruleId = rule.id,
|
||||
message = it.message ?: "Invalid replacement.",
|
||||
)
|
||||
}
|
||||
.getOrNull() ?: return@forEach
|
||||
if (next != current) {
|
||||
applied += rule.id
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
return ReaderWordReplacementApplyResult(
|
||||
text = current,
|
||||
appliedRuleIds = applied,
|
||||
errors = errors,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderWordReplacementRule.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)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ReaderBookReplacementPreferences(
|
||||
val fileRules: Map<String, List<ReaderWordReplacementRule>> = emptyMap(),
|
||||
) {
|
||||
fun rulesForFile(fileId: String?): List<ReaderWordReplacementRule> {
|
||||
return fileRules[fileId.orEmpty()].orEmpty()
|
||||
}
|
||||
|
||||
fun activeRulesForFile(fileId: String?): List<ReaderWordReplacementRule> {
|
||||
return rulesForFile(fileId).filter { it.enabled && it.from.isNotBlank() }
|
||||
}
|
||||
|
||||
fun withFileRules(
|
||||
fileId: String?,
|
||||
rules: List<ReaderWordReplacementRule>,
|
||||
): ReaderBookReplacementPreferences {
|
||||
val key = fileId.orEmpty()
|
||||
val nextRules = if (rules.isEmpty()) {
|
||||
fileRules - key
|
||||
} else {
|
||||
fileRules + (key to rules)
|
||||
}
|
||||
return copy(fileRules = nextRules)
|
||||
}
|
||||
|
||||
fun scopedToFile(fileId: String?): ReaderBookReplacementPreferences {
|
||||
val key = fileId.orEmpty()
|
||||
val rules = rulesForFile(key)
|
||||
return if (rules.isEmpty()) {
|
||||
ReaderBookReplacementPreferences()
|
||||
} else {
|
||||
ReaderBookReplacementPreferences(fileRules = mapOf(key to rules))
|
||||
}
|
||||
}
|
||||
|
||||
fun signatureForFile(fileId: String?): String {
|
||||
return activeRulesForFile(fileId).joinToString(separator = "|") { rule ->
|
||||
listOf(
|
||||
rule.id,
|
||||
rule.from,
|
||||
rule.to,
|
||||
rule.enabled.toString(),
|
||||
rule.isRegex.toString(),
|
||||
rule.matchCase.toString(),
|
||||
rule.wholeWord.toString(),
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ReaderBookReplacementEngine {
|
||||
fun validate(rule: ReaderWordReplacementRule): ReaderWordReplacementValidation {
|
||||
return ReaderWordReplacementEngine.validate(rule)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
text: String,
|
||||
preferences: ReaderBookReplacementPreferences,
|
||||
fileId: String?,
|
||||
): ReaderWordReplacementApplyResult {
|
||||
return ReaderWordReplacementEngine.apply(
|
||||
text = text,
|
||||
rules = preferences.activeRulesForFile(fileId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object ReaderBookReplacementPreferencesJson {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
fun encode(preferences: ReaderBookReplacementPreferences): String {
|
||||
return json.encodeToString(preferences)
|
||||
}
|
||||
|
||||
fun decodeOrEmpty(raw: String?): ReaderBookReplacementPreferences {
|
||||
if (raw.isNullOrBlank()) return ReaderBookReplacementPreferences()
|
||||
return runCatching {
|
||||
json.decodeFromString<ReaderBookReplacementPreferences>(raw)
|
||||
}.getOrNull() ?: ReaderBookReplacementPreferences()
|
||||
}
|
||||
}
|
||||
|
|
@ -646,6 +646,7 @@ data class SharedSettingsHubInput(
|
|||
val isSignedIn: Boolean = false,
|
||||
val isProUser: Boolean = false,
|
||||
val accountAvailable: Boolean = true,
|
||||
val includeAccountAuthActions: Boolean = true,
|
||||
val syncAvailable: Boolean = true,
|
||||
val folderSyncAvailable: Boolean = true,
|
||||
val aiSettingsAvailable: Boolean = true,
|
||||
|
|
@ -750,7 +751,7 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
|
|||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.SYNC_ACCOUNTS,
|
||||
items = buildList {
|
||||
if (input.accountAvailable && input.featurePolicy.aiAndCloud) {
|
||||
if (input.includeAccountAuthActions && input.accountAvailable && input.featurePolicy.aiAndCloud) {
|
||||
if (input.isSignedIn) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ data class SharedFeaturePolicy(
|
|||
) {
|
||||
companion object {
|
||||
val Standard = SharedFeaturePolicy()
|
||||
val OssOnline = SharedFeaturePolicy(
|
||||
networkAccess = true,
|
||||
aiAndCloud = true,
|
||||
byokAi = true
|
||||
)
|
||||
val OssOffline = SharedFeaturePolicy(
|
||||
networkAccess = false,
|
||||
opdsCatalogs = false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package com.aryan.reader.shared
|
||||
|
||||
const val EPISTEME_POLICY_BASE_URL = "https://aryan-raj3112.github.io/reader-policy"
|
||||
|
||||
enum class SharedLegalProfile {
|
||||
STANDARD,
|
||||
OSS
|
||||
}
|
||||
|
||||
data class SharedLegalLinks(
|
||||
val privacyPolicyUrl: String,
|
||||
val termsUrl: String,
|
||||
val licensesUrl: String
|
||||
)
|
||||
|
||||
fun sharedLegalLinksForProfile(profile: SharedLegalProfile): SharedLegalLinks {
|
||||
val privacyPath: String
|
||||
val termsPath: String
|
||||
when (profile) {
|
||||
SharedLegalProfile.STANDARD -> {
|
||||
privacyPath = "privacy-policy.html"
|
||||
termsPath = "terms-and-conditions.html"
|
||||
}
|
||||
SharedLegalProfile.OSS -> {
|
||||
privacyPath = "oss-privacy-policy.html"
|
||||
termsPath = "oss-terms-of-service.html"
|
||||
}
|
||||
}
|
||||
return SharedLegalLinks(
|
||||
privacyPolicyUrl = "$EPISTEME_POLICY_BASE_URL/$privacyPath",
|
||||
termsUrl = "$EPISTEME_POLICY_BASE_URL/$termsPath",
|
||||
licensesUrl = "$EPISTEME_POLICY_BASE_URL/licenses.html"
|
||||
)
|
||||
}
|
||||
|
|
@ -45,8 +45,10 @@ data class SharedLibrarySnapshot(
|
|||
val appSeedColor: Color? = null,
|
||||
val appFontPreference: AppFontPreference = AppFontPreference.System,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val customReaderThemes: List<ReaderTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
val desktopReaderDefaultsVersion: Int = 0,
|
||||
val readerToolbarPreferences: ReaderToolbarPreferences = ReaderToolbarPreferences(),
|
||||
val readerHighlightPalette: ReaderHighlightPalette = ReaderHighlightPalette(),
|
||||
val pdfHighlighterPalette: SharedPdfHighlighterPalette = SharedPdfHighlighterPalette(),
|
||||
|
|
@ -54,7 +56,7 @@ data class SharedLibrarySnapshot(
|
|||
)
|
||||
|
||||
object SharedLibrarySnapshotJson {
|
||||
private const val SCHEMA_VERSION = 20
|
||||
private const val SCHEMA_VERSION = 22
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
|
|
@ -106,11 +108,15 @@ object SharedLibrarySnapshotJson {
|
|||
?.asAppFontPreferenceOrNull()
|
||||
?: AppFontPreference.System,
|
||||
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
|
||||
customReaderThemes = root.array("customReaderThemes")
|
||||
.mapNotNull { it.asReaderThemeOrNull() }
|
||||
.sanitizeCustomReaderThemes(),
|
||||
readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion),
|
||||
pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderSettingsOrNull()
|
||||
?: ReaderSettings(themeId = "no_theme"),
|
||||
desktopReaderDefaultsVersion = root.int("desktopReaderDefaultsVersion", 0),
|
||||
readerToolbarPreferences = root["readerToolbarPreferences"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asReaderToolbarPreferencesOrNull()
|
||||
|
|
@ -154,8 +160,12 @@ object SharedLibrarySnapshotJson {
|
|||
"appSeedColor" to snapshot.appSeedColor.asJson(),
|
||||
"appFontPreference" to snapshot.appFontPreference.sanitized().toJsonObject(),
|
||||
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
|
||||
"customReaderThemes" to JsonArray(
|
||||
snapshot.customReaderThemes.sanitizeCustomReaderThemes().map { it.toJsonObject() }
|
||||
),
|
||||
"readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(),
|
||||
"pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(),
|
||||
"desktopReaderDefaultsVersion" to JsonPrimitive(snapshot.desktopReaderDefaultsVersion),
|
||||
"readerToolbarPreferences" to snapshot.readerToolbarPreferences.sanitized().toJsonObject(),
|
||||
"readerHighlightPalette" to snapshot.readerHighlightPalette.sanitized().toJsonObject(),
|
||||
"pdfHighlighterPalette" to snapshot.pdfHighlighterPalette.sanitized().toJsonObject(),
|
||||
|
|
@ -279,7 +289,8 @@ private fun JsonElement.asBookItemOrNull(): BookItem? {
|
|||
readerSettings = obj["readerSettings"]?.takeUnless { it is JsonNull }?.asReaderSettingsOrNull(),
|
||||
readerBookmarks = obj.array("readerBookmarks").mapNotNull { it.asReaderBookmarkOrNull() },
|
||||
readerHighlights = obj.array("readerHighlights").mapNotNull { it.asReaderHighlightOrNull() },
|
||||
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull()
|
||||
pdfReaderViewport = obj["pdfReaderViewport"]?.takeUnless { it is JsonNull }?.asSharedPdfReaderViewportOrNull(),
|
||||
readingPositionModifiedTimestamp = obj.long("readingPositionModifiedTimestamp")
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +347,8 @@ private fun JsonElement.asSyncedFolderOrNull(): SyncedFolder? {
|
|||
.mapNotNull { runCatching { FileType.valueOf(it) }.getOrNull() }
|
||||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.toSet()
|
||||
.ifEmpty { SharedFileCapabilities.knownFileTypes }
|
||||
.ifEmpty { SharedFileCapabilities.knownFileTypes },
|
||||
localSyncEnabled = obj.boolean("localSyncEnabled", true)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +361,19 @@ private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
|
|||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asReaderThemeOrNull(): ReaderTheme? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
return ReaderTheme(
|
||||
id = obj.string("id") ?: return null,
|
||||
name = obj.string("name") ?: return null,
|
||||
backgroundColor = obj.int("bgColor")?.let { Color(it) } ?: return null,
|
||||
textColor = obj.int("textColor")?.let { Color(it) } ?: return null,
|
||||
isDark = obj.boolean("isDark", false),
|
||||
textureId = obj.string("textureId")?.takeIf { it.isNotBlank() },
|
||||
isCustom = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asAppFontPreferenceOrNull(): AppFontPreference? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val kind = obj.string("kind")
|
||||
|
|
@ -391,7 +416,8 @@ private fun BookItem.toJsonObject(): JsonObject {
|
|||
"readerSettings" to readerSettings.asJson(),
|
||||
"readerBookmarks" to JsonArray(readerBookmarks.map { it.toJsonObject() }),
|
||||
"readerHighlights" to JsonArray(readerHighlights.map { it.toJsonObject() }),
|
||||
"pdfReaderViewport" to pdfReaderViewport.asJson()
|
||||
"pdfReaderViewport" to pdfReaderViewport.asJson(),
|
||||
"readingPositionModifiedTimestamp" to JsonPrimitive(readingPositionModifiedTimestamp)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -451,7 +477,8 @@ private fun SyncedFolder.toJsonObject(): JsonObject {
|
|||
.filter { it in SharedFileCapabilities.knownFileTypes }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
.asJsonArray()
|
||||
.asJsonArray(),
|
||||
"localSyncEnabled" to JsonPrimitive(localSyncEnabled)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -466,6 +493,18 @@ private fun CustomAppTheme.toJsonObject(): JsonObject {
|
|||
)
|
||||
}
|
||||
|
||||
private fun ReaderTheme.toJsonObject(): JsonObject {
|
||||
val values = mutableMapOf<String, JsonElement>(
|
||||
"id" to JsonPrimitive(id),
|
||||
"name" to JsonPrimitive(name),
|
||||
"bgColor" to JsonPrimitive(backgroundColor.toArgb()),
|
||||
"textColor" to JsonPrimitive(textColor.toArgb()),
|
||||
"isDark" to JsonPrimitive(isDark)
|
||||
)
|
||||
textureId?.let { values["textureId"] = JsonPrimitive(it) }
|
||||
return JsonObject(values)
|
||||
}
|
||||
|
||||
private fun AppFontPreference.toJsonObject(): JsonObject {
|
||||
val sanitized = sanitized()
|
||||
return JsonObject(
|
||||
|
|
@ -529,6 +568,7 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
|||
pageSpreadMode = obj.string("pageSpreadMode")
|
||||
?.let { runCatching { ReaderPageSpreadMode.valueOf(it) }.getOrNull() }
|
||||
?: defaults.pageSpreadMode,
|
||||
rightToLeftPagination = obj.boolean("rightToLeftPagination", defaults.rightToLeftPagination),
|
||||
pdfVerticalPageGapVisible = obj.boolean(
|
||||
"pdfVerticalPageGapVisible",
|
||||
defaults.pdfVerticalPageGapVisible
|
||||
|
|
@ -650,6 +690,8 @@ private fun JsonElement.asReaderLocatorOrNull(): ReaderLocator? {
|
|||
pageIndex = obj.int("pageIndex"),
|
||||
startOffset = obj.int("startOffset"),
|
||||
endOffset = obj.int("endOffset"),
|
||||
blockIndex = obj.int("blockIndex"),
|
||||
charOffset = obj.int("charOffset"),
|
||||
textQuote = obj.string("textQuote"),
|
||||
cfi = obj.string("cfi")
|
||||
)
|
||||
|
|
@ -681,6 +723,7 @@ private fun ReaderSettings?.asJson(): JsonElement {
|
|||
"pageInfoMode" to JsonPrimitive(settings.pageInfoMode.name),
|
||||
"pageInfoPosition" to JsonPrimitive(settings.pageInfoPosition.name),
|
||||
"pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name),
|
||||
"rightToLeftPagination" to JsonPrimitive(settings.rightToLeftPagination),
|
||||
"pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible),
|
||||
"pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible),
|
||||
"pdfFirstPageStandaloneInSpread" to JsonPrimitive(settings.pdfFirstPageStandaloneInSpread),
|
||||
|
|
@ -767,6 +810,8 @@ private fun ReaderLocator.toJsonObject(): JsonObject {
|
|||
pageIndex?.let { put("pageIndex", JsonPrimitive(it)) }
|
||||
startOffset?.let { put("startOffset", JsonPrimitive(it)) }
|
||||
endOffset?.let { put("endOffset", JsonPrimitive(it)) }
|
||||
blockIndex?.let { put("blockIndex", JsonPrimitive(it)) }
|
||||
charOffset?.let { put("charOffset", JsonPrimitive(it)) }
|
||||
textQuote?.let { put("textQuote", JsonPrimitive(it)) }
|
||||
cfi?.let { put("cfi", JsonPrimitive(it)) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
appSeedColor = if (shouldClearSeed) null else appSeedColor
|
||||
)
|
||||
}
|
||||
is AppAction.CustomReaderThemesChanged -> copy(
|
||||
customReaderThemes = action.themes.sanitizeCustomReaderThemes()
|
||||
)
|
||||
is AppAction.SyncEnabledChanged -> copy(isSyncEnabled = action.enabled)
|
||||
is AppAction.FolderSyncEnabledChanged -> copy(isFolderSyncEnabled = action.enabled)
|
||||
is AppAction.TabsEnabledChanged -> copy(
|
||||
|
|
@ -101,9 +104,11 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
if (bookId.isBlank()) {
|
||||
this
|
||||
} else {
|
||||
val currentTabIds = openTabIds.distinct()
|
||||
val nextTabIds = if (bookId in currentTabIds) currentTabIds else currentTabIds + bookId
|
||||
copy(
|
||||
isTabsEnabled = true,
|
||||
openTabIds = (openTabIds - bookId) + bookId,
|
||||
openTabIds = nextTabIds,
|
||||
activeTabBookId = bookId
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,18 @@ data class OpdsAcquisition(
|
|||
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("cbt", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook+tar", ignoreCase = true) ||
|
||||
mimeType.contains("x-tar", ignoreCase = true) ||
|
||||
mimeType.equals("application/tar", ignoreCase = true) -> "CBT"
|
||||
mimeType.contains("cbr", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook-rar", ignoreCase = true) ||
|
||||
mimeType.contains("rar", ignoreCase = true) -> "CBR"
|
||||
mimeType.contains("cb7", ignoreCase = true) ||
|
||||
mimeType.contains("7z", ignoreCase = true) -> "CB7"
|
||||
mimeType.contains("cbz", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook+zip", ignoreCase = true) ||
|
||||
mimeType.contains("comicbook", ignoreCase = true) -> "CBZ"
|
||||
mimeType.contains("txt", ignoreCase = true) ||
|
||||
mimeType.contains("text/plain", ignoreCase = true) -> "TXT"
|
||||
else -> mimeType.substringAfterLast("/").uppercase()
|
||||
|
|
@ -63,7 +71,7 @@ data class OpdsAcquisition(
|
|||
"PPTX" -> 4
|
||||
"MOBI" -> 3
|
||||
"FB2", "MD", "HTML" -> 2
|
||||
"CBZ", "CBR", "CB7" -> 1
|
||||
"CBZ", "CBR", "CB7", "CBT" -> 1
|
||||
"TXT" -> 0
|
||||
else -> -1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.aryan.reader.shared.opds
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
|
||||
object SharedOpdsSearch {
|
||||
|
|
@ -18,7 +19,14 @@ object SharedOpdsSearch {
|
|||
|
||||
fun expandSearchTemplate(template: String, query: String): String {
|
||||
val encoded = query.percentEncode()
|
||||
val expandedSearchTerms = template.replace("{searchTerms}", encoded)
|
||||
val expandedSearchTerms = template
|
||||
.replace("{searchTerms}", encoded)
|
||||
.replace("{count}", DefaultSearchCount)
|
||||
.replace("{startPage}", DefaultSearchStartPage)
|
||||
.replace("{startIndex}", DefaultSearchStartIndex)
|
||||
.replace("{language}", DefaultSearchLanguage)
|
||||
.replace("{inputEncoding}", DefaultSearchEncoding)
|
||||
.replace("{outputEncoding}", DefaultSearchEncoding)
|
||||
if (expandedSearchTerms != template) return expandedSearchTerms
|
||||
|
||||
val queryTemplate = Regex("""\{([?&])([^}]+)\}""").find(template)
|
||||
|
|
@ -56,6 +64,12 @@ object SharedOpdsSearch {
|
|||
contains("{query}") ||
|
||||
contains("{keyword}")
|
||||
}
|
||||
|
||||
private const val DefaultSearchCount = "12"
|
||||
private const val DefaultSearchStartPage = "1"
|
||||
private const val DefaultSearchStartIndex = "1"
|
||||
private const val DefaultSearchLanguage = "*"
|
||||
private const val DefaultSearchEncoding = "UTF-8"
|
||||
}
|
||||
|
||||
object SharedOpdsDownloadNamer {
|
||||
|
|
@ -82,6 +96,7 @@ object SharedOpdsDownloadNamer {
|
|||
"CBZ" -> ".cbz"
|
||||
"CBR" -> ".cbr"
|
||||
"CB7" -> ".cb7"
|
||||
"CBT" -> ".cbt"
|
||||
"MD" -> ".md"
|
||||
"HTML" -> ".html"
|
||||
"TXT" -> ".txt"
|
||||
|
|
@ -125,6 +140,101 @@ object SharedOpdsDownloadNamer {
|
|||
}
|
||||
}
|
||||
|
||||
object SharedOpdsLocalBookMatcher {
|
||||
fun findBook(entry: OpdsEntry, books: List<BookItem>): BookItem? {
|
||||
return find(
|
||||
entry = entry,
|
||||
books = books,
|
||||
title = { it.title },
|
||||
displayName = { it.displayName },
|
||||
path = { it.path }
|
||||
)
|
||||
}
|
||||
|
||||
fun <T> find(
|
||||
entry: OpdsEntry,
|
||||
books: List<T>,
|
||||
title: (T) -> String?,
|
||||
displayName: (T) -> String?,
|
||||
path: (T) -> String?
|
||||
): T? {
|
||||
val entryKeys = entry.matchKeys()
|
||||
return books.firstOrNull { book ->
|
||||
book.matchKeys(title, displayName, path).any { it in entryKeys }
|
||||
}
|
||||
}
|
||||
|
||||
private fun OpdsEntry.matchKeys(): Set<String> {
|
||||
return buildSet {
|
||||
addNormalized(title)
|
||||
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(title)
|
||||
addNormalized(safeTitle)
|
||||
addNormalized(safeTitle.take(50))
|
||||
acquisitions.forEach { acquisition ->
|
||||
addFileNameKeys(acquisition.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> T.matchKeys(
|
||||
title: (T) -> String?,
|
||||
displayName: (T) -> String?,
|
||||
path: (T) -> String?
|
||||
): Set<String> {
|
||||
return buildSet {
|
||||
addNormalized(title(this@matchKeys))
|
||||
addFileNameKeys(displayName(this@matchKeys))
|
||||
addFileNameKeys(path(this@matchKeys))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableSet<String>.addFileNameKeys(value: String?) {
|
||||
val decodedName = value
|
||||
?.substringBefore('?')
|
||||
?.substringBefore('#')
|
||||
?.substringAfterLast('/')
|
||||
?.substringAfterLast('\\')
|
||||
?.percentDecode()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
addNormalized(decodedName)
|
||||
addNormalized(decodedName.withoutKnownExtension())
|
||||
addNormalized(decodedName.withoutKnownExtension().withoutOpdsDownloadPrefix())
|
||||
}
|
||||
|
||||
private fun MutableSet<String>.addNormalized(value: String?) {
|
||||
val normalized = value?.normalizedMatchKey() ?: return
|
||||
if (normalized.isNotBlank()) add(normalized)
|
||||
}
|
||||
|
||||
private fun String.withoutKnownExtension(): String {
|
||||
val knownSuffix = SharedFileCapabilities.fileExtensionSuffixForName(this)
|
||||
if (knownSuffix != null && endsWith(knownSuffix, ignoreCase = true)) {
|
||||
return dropLast(knownSuffix.length)
|
||||
}
|
||||
val extension = substringAfterLast('.', missingDelimiterValue = "")
|
||||
return if (extension.length in 1..8 && extension.all { it.isLetterOrDigit() }) {
|
||||
substringBeforeLast('.')
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.normalizedMatchKey(): String {
|
||||
return percentDecode()
|
||||
.withoutOpdsDownloadPrefix()
|
||||
.replace(Regex("""[^\p{L}\p{N}]+"""), " ")
|
||||
.trim()
|
||||
.lowercase()
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.removePrefix("opds dl ")
|
||||
}
|
||||
|
||||
private fun String.withoutOpdsDownloadPrefix(): String {
|
||||
return replace(Regex("""^opds[_\-\s]+dl[_\-\s]+""", RegexOption.IGNORE_CASE), "")
|
||||
}
|
||||
}
|
||||
|
||||
object SharedOpdsStreamUri {
|
||||
private const val SCHEME_PREFIX = "opds-pse://stream"
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,38 @@ data class SharedPdfAnnotationComment(
|
|||
val modifiedAt: Long = 0L
|
||||
)
|
||||
|
||||
const val DEFAULT_SHARED_PDF_COMMENT_AUTHOR = "Reader"
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.visiblePdfAnnotationComments(): List<SharedPdfAnnotationComment> {
|
||||
val visibleCommentIds = filter { it.contents.isNotBlank() }.map { it.id }.toSet()
|
||||
return filter { it.contents.isNotBlank() }
|
||||
.map { comment ->
|
||||
if (comment.parentId != null && comment.parentId !in visibleCommentIds) {
|
||||
comment.copy(parentId = null)
|
||||
} else {
|
||||
comment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.pdfCommentChildren(parentId: String?): List<SharedPdfAnnotationComment> {
|
||||
return filter { it.parentId == parentId }
|
||||
.sortedWith(compareBy({ it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }, { it.id }))
|
||||
}
|
||||
|
||||
fun List<SharedPdfAnnotationComment>.withoutPdfCommentThread(commentId: String): List<SharedPdfAnnotationComment> {
|
||||
val childrenByParentId = groupBy { it.parentId }
|
||||
val idsToRemove = mutableSetOf<String>()
|
||||
|
||||
fun collect(id: String) {
|
||||
if (!idsToRemove.add(id)) return
|
||||
childrenByParentId[id].orEmpty().forEach { child -> collect(child.id) }
|
||||
}
|
||||
|
||||
collect(commentId)
|
||||
return filterNot { it.id in idsToRemove }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfAnnotation(
|
||||
val id: String,
|
||||
|
|
@ -162,13 +194,7 @@ object SharedPdfAnnotationDefaults {
|
|||
0xFFFFFFFF.toInt()
|
||||
)
|
||||
|
||||
val highlighterPalette: List<Int> = listOf(
|
||||
0x8CFF9800.toInt(),
|
||||
0x8CFFEB3B.toInt(),
|
||||
0x8C81C784.toInt(),
|
||||
0x8C64B5F6.toInt(),
|
||||
0x8CE1BEE7.toInt()
|
||||
)
|
||||
val highlighterPalette: List<Int> = SharedPdfAndroidHighlightColors.palette.take(4)
|
||||
|
||||
fun configFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return when (tool) {
|
||||
|
|
@ -176,8 +202,8 @@ object SharedPdfAnnotationDefaults {
|
|||
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.HIGHLIGHTER -> PdfToolConfig(highlighterPalette[0], 0.035f)
|
||||
PdfInkTool.HIGHLIGHTER_ROUND -> PdfToolConfig(highlighterPalette[1], 0.035f)
|
||||
PdfInkTool.ERASER -> PdfToolConfig(0x00000000, 0.03f)
|
||||
PdfInkTool.TEXT -> PdfToolConfig(0xFF000000.toInt(), 0.02f)
|
||||
}
|
||||
|
|
@ -209,9 +235,11 @@ data class SharedPdfHighlighterPalette(
|
|||
|
||||
companion object {
|
||||
const val DefaultAlpha: Int = 0x8C
|
||||
const val MaxColors: Int = 5
|
||||
const val MaxColors: Int = 4
|
||||
val defaultColors: List<Int>
|
||||
get() = SharedPdfAnnotationDefaults.highlighterPalette.map { it.withPdfHighlighterAlpha() }
|
||||
get() = SharedPdfAnnotationDefaults.highlighterPalette
|
||||
.take(MaxColors)
|
||||
.map { it.withPdfHighlighterAlpha() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,18 +247,21 @@ object SharedPdfAndroidHighlightColors {
|
|||
const val StoredAlpha: Int = 0x8C
|
||||
const val RenderAlpha: Float = 0.4f
|
||||
|
||||
val orderedNames: List<String> = listOf("ORANGE", "YELLOW", "GREEN", "BLUE", "PURPLE")
|
||||
|
||||
val colorsByName: Map<String, Int> = mapOf(
|
||||
"YELLOW" to 0xFFFBC02D.toInt(),
|
||||
"GREEN" to 0xFF388E3C.toInt(),
|
||||
"BLUE" to 0xFF1976D2.toInt(),
|
||||
"RED" to 0xFFD32F2F.toInt()
|
||||
"ORANGE" to 0xFFFF9800.toInt(),
|
||||
"YELLOW" to 0xFFFFEB3B.toInt(),
|
||||
"GREEN" to 0xFF81C784.toInt(),
|
||||
"BLUE" to 0xFF64B5F6.toInt(),
|
||||
"PURPLE" to 0xFFE1BEE7.toInt()
|
||||
)
|
||||
|
||||
val palette: List<Int>
|
||||
get() = colorsByName.keys.map(::argbForName)
|
||||
get() = orderedNames.map(::argbForName)
|
||||
|
||||
fun argbForName(name: String): Int {
|
||||
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("YELLOW")
|
||||
val opaqueArgb = colorsByName[name.uppercase()] ?: colorsByName.getValue("ORANGE")
|
||||
return (StoredAlpha shl 24) or (opaqueArgb and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
|
|
@ -242,7 +273,7 @@ object SharedPdfAndroidHighlightColors {
|
|||
val dg = ((rgb shr 8) and 0xFF) - ((candidate shr 8) and 0xFF)
|
||||
val db = (rgb and 0xFF) - (candidate and 0xFF)
|
||||
dr * dr + dg * dg + db * db
|
||||
}?.key ?: "YELLOW"
|
||||
}?.key ?: "ORANGE"
|
||||
}
|
||||
|
||||
fun nearestArgb(argb: Int): Int {
|
||||
|
|
|
|||
|
|
@ -186,12 +186,20 @@ data class SharedPdfReaderState(
|
|||
val isTextSelectionMode: Boolean = false,
|
||||
val bookmarks: List<SharedPdfBookmark> = emptyList(),
|
||||
val selectedAnnotationId: String? = null,
|
||||
val annotations: List<SharedPdfAnnotation> = emptyList()
|
||||
val annotations: List<SharedPdfAnnotation> = emptyList(),
|
||||
val toolConfigs: Map<PdfInkTool, PdfToolConfig> = emptyMap(),
|
||||
val penPalette: List<Int> = SharedPdfAnnotationDefaults.penPalette,
|
||||
val lastActivePenTool: PdfInkTool = PdfInkTool.PEN,
|
||||
val lastActiveHighlighterTool: PdfInkTool = PdfInkTool.HIGHLIGHTER,
|
||||
val annotationUndoStack: List<SharedPdfAnnotationHistoryAction> = emptyList(),
|
||||
val annotationRedoStack: List<SharedPdfAnnotationHistoryAction> = 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 canUndoAnnotationEdit: Boolean get() = annotationUndoStack.isNotEmpty()
|
||||
val canRedoAnnotationEdit: Boolean get() = annotationRedoStack.isNotEmpty()
|
||||
val progressPercent: Float get() = ((pageIndex + 1).toFloat() / safePageCount.coerceAtLeast(1)) * 100f
|
||||
|
||||
fun coerced(zoomSpec: PdfZoomSpec = PdfZoomSpec()): SharedPdfReaderState {
|
||||
|
|
@ -202,6 +210,10 @@ data class SharedPdfReaderState(
|
|||
activeSearchResultIndex = activeSearchResultIndex.coerceAtLeast(-1),
|
||||
zoom = zoomSpec.clamp(zoom),
|
||||
bookmarks = bookmarks.normalizedBookmarks(lastPageIndex),
|
||||
penPalette = penPalette.sanitizedSharedPdfPenPalette(),
|
||||
lastActivePenTool = lastActivePenTool.takeIf { it.isSharedPdfPenTool } ?: PdfInkTool.PEN,
|
||||
lastActiveHighlighterTool = lastActiveHighlighterTool.takeIf { it.isSharedPdfHighlighterTool }
|
||||
?: PdfInkTool.HIGHLIGHTER,
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { selectedId ->
|
||||
annotations.any { it.id == selectedId }
|
||||
}
|
||||
|
|
@ -225,6 +237,11 @@ data class SharedPdfReaderState(
|
|||
}
|
||||
}
|
||||
|
||||
sealed interface SharedPdfAnnotationHistoryAction {
|
||||
data class Add(val pageIndex: Int, val annotation: SharedPdfAnnotation) : SharedPdfAnnotationHistoryAction
|
||||
data class Remove(val itemsByPage: Map<Int, List<SharedPdfAnnotation>>) : SharedPdfAnnotationHistoryAction
|
||||
}
|
||||
|
||||
sealed interface SharedPdfReaderAction {
|
||||
data class GoToPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data object PreviousPage : SharedPdfReaderAction
|
||||
|
|
@ -248,6 +265,7 @@ sealed interface SharedPdfReaderAction {
|
|||
data class ToolSelected(val tool: PdfInkTool) : SharedPdfReaderAction
|
||||
data class ColorSelected(val colorArgb: Int) : SharedPdfReaderAction
|
||||
data class StrokeWidthChanged(val strokeWidth: Float) : SharedPdfReaderAction
|
||||
data class PenPaletteChanged(val colors: List<Int>) : SharedPdfReaderAction
|
||||
data class TextSelectionModeChanged(val enabled: Boolean) : SharedPdfReaderAction
|
||||
data class BookmarksLoaded(val bookmarks: List<SharedPdfBookmark>) : SharedPdfReaderAction
|
||||
data class BookmarkToggled(
|
||||
|
|
@ -262,6 +280,8 @@ sealed interface SharedPdfReaderAction {
|
|||
data class AnnotationDeleted(val annotationId: String) : SharedPdfReaderAction
|
||||
data class AnnotationsChanged(val annotations: List<SharedPdfAnnotation>) : SharedPdfReaderAction
|
||||
data class UndoLastAnnotationOnPage(val pageIndex: Int) : SharedPdfReaderAction
|
||||
data object UndoAnnotationEdit : SharedPdfReaderAction
|
||||
data object RedoAnnotationEdit : SharedPdfReaderAction
|
||||
data class ClearPageAnnotations(val pageIndex: Int) : SharedPdfReaderAction
|
||||
}
|
||||
|
||||
|
|
@ -327,16 +347,23 @@ fun SharedPdfReaderState.reduce(
|
|||
}
|
||||
}
|
||||
is SharedPdfReaderAction.ToolSelected -> {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(action.tool)
|
||||
val config = toolConfigFor(action.tool)
|
||||
copy(
|
||||
selectedTool = action.tool,
|
||||
selectedColorArgb = config.colorArgb,
|
||||
strokeWidth = config.strokeWidth,
|
||||
isTextSelectionMode = false
|
||||
isTextSelectionMode = false,
|
||||
lastActivePenTool = if (action.tool.isSharedPdfPenTool) action.tool else lastActivePenTool,
|
||||
lastActiveHighlighterTool = if (action.tool.isSharedPdfHighlighterTool) {
|
||||
action.tool
|
||||
} else {
|
||||
lastActiveHighlighterTool
|
||||
}
|
||||
)
|
||||
}
|
||||
is SharedPdfReaderAction.ColorSelected -> copy(selectedColorArgb = action.colorArgb)
|
||||
is SharedPdfReaderAction.StrokeWidthChanged -> copy(strokeWidth = action.strokeWidth.coerceAtLeast(0.0001f))
|
||||
is SharedPdfReaderAction.ColorSelected -> withActiveToolColor(action.colorArgb)
|
||||
is SharedPdfReaderAction.StrokeWidthChanged -> withActiveToolStrokeWidth(action.strokeWidth.coerceAtLeast(0.0001f))
|
||||
is SharedPdfReaderAction.PenPaletteChanged -> copy(penPalette = action.colors.sanitizedSharedPdfPenPalette())
|
||||
is SharedPdfReaderAction.TextSelectionModeChanged -> {
|
||||
if (action.enabled) {
|
||||
val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.NONE)
|
||||
|
|
@ -365,10 +392,19 @@ fun SharedPdfReaderState.reduce(
|
|||
}
|
||||
copy(bookmarks = nextBookmarks.normalizedBookmarks(lastPageIndex))
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationsLoaded -> copy(annotations = action.annotations.toList())
|
||||
is SharedPdfReaderAction.AnnotationsLoaded -> copy(
|
||||
annotations = action.annotations.toList(),
|
||||
annotationUndoStack = emptyList(),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationAdded -> copy(
|
||||
annotations = annotations + action.annotation,
|
||||
selectedAnnotationId = action.annotation.id
|
||||
selectedAnnotationId = action.annotation.id,
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Add(
|
||||
pageIndex = action.annotation.pageIndex,
|
||||
annotation = action.annotation
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
is SharedPdfReaderAction.AnnotationSelected -> copy(
|
||||
selectedAnnotationId = action.annotationId?.takeIf { id -> annotations.any { it.id == id } }
|
||||
|
|
@ -378,36 +414,158 @@ fun SharedPdfReaderState.reduce(
|
|||
if (index < 0) {
|
||||
this
|
||||
} else {
|
||||
copy(annotations = annotations.toMutableList().also { it[index] = action.annotation })
|
||||
copy(
|
||||
annotations = annotations.toMutableList().also { it[index] = action.annotation },
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationDeleted -> copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotationId },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId }
|
||||
is SharedPdfReaderAction.AnnotationDeleted -> {
|
||||
val removed = annotations.firstOrNull { it.id == action.annotationId }
|
||||
if (removed == null) {
|
||||
this
|
||||
} else {
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotationId },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotationId },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(removed.pageIndex to listOf(removed))
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
is SharedPdfReaderAction.AnnotationsChanged -> copy(
|
||||
annotations = action.annotations.toList(),
|
||||
annotationUndoStack = emptyList(),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
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 removed = annotations[index]
|
||||
val removedId = annotations[index].id
|
||||
copy(
|
||||
annotations = annotations.toMutableList().also { it.removeAt(index) },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId }
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != removedId },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(removed.pageIndex to listOf(removed))
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedPdfReaderAction.UndoAnnotationEdit -> undoSharedPdfAnnotationEdit()
|
||||
SharedPdfReaderAction.RedoAnnotationEdit -> redoSharedPdfAnnotationEdit()
|
||||
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 }
|
||||
)
|
||||
val removed = annotations.filter { it.pageIndex == action.pageIndex }
|
||||
if (removed.isEmpty()) {
|
||||
this
|
||||
} else {
|
||||
val removedIds = removed.mapTo(mutableSetOf()) { it.id }
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.pageIndex == action.pageIndex },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds },
|
||||
annotationUndoStack = annotationUndoStack + SharedPdfAnnotationHistoryAction.Remove(
|
||||
itemsByPage = mapOf(action.pageIndex to removed)
|
||||
),
|
||||
annotationRedoStack = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}.coerced(zoomSpec)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.toolConfigFor(tool: PdfInkTool): PdfToolConfig {
|
||||
return toolConfigs[tool] ?: SharedPdfAnnotationDefaults.configFor(tool)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.withActiveToolColor(colorArgb: Int): SharedPdfReaderState {
|
||||
if (!selectedTool.isSharedPdfConfigurableTool) {
|
||||
return copy(selectedColorArgb = colorArgb)
|
||||
}
|
||||
val currentConfig = toolConfigFor(selectedTool)
|
||||
return copy(
|
||||
selectedColorArgb = colorArgb,
|
||||
toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(colorArgb = colorArgb))
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.withActiveToolStrokeWidth(strokeWidth: Float): SharedPdfReaderState {
|
||||
if (!selectedTool.isSharedPdfConfigurableTool) {
|
||||
return copy(strokeWidth = strokeWidth)
|
||||
}
|
||||
val currentConfig = toolConfigFor(selectedTool)
|
||||
return copy(
|
||||
strokeWidth = strokeWidth,
|
||||
toolConfigs = toolConfigs + (selectedTool to currentConfig.copy(strokeWidth = strokeWidth))
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<Int>.sanitizedSharedPdfPenPalette(): List<Int> {
|
||||
val defaults = SharedPdfAnnotationDefaults.penPalette
|
||||
val normalized = filter { it != 0 }.take(defaults.size)
|
||||
val filled = if (normalized.isEmpty()) {
|
||||
defaults
|
||||
} else {
|
||||
normalized + defaults.drop(normalized.size)
|
||||
}
|
||||
return filled.take(defaults.size)
|
||||
}
|
||||
|
||||
private val PdfInkTool.isSharedPdfPenTool: Boolean
|
||||
get() = this == PdfInkTool.FOUNTAIN_PEN || this == PdfInkTool.PEN || this == PdfInkTool.PENCIL
|
||||
|
||||
private val PdfInkTool.isSharedPdfHighlighterTool: Boolean
|
||||
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
|
||||
private val PdfInkTool.isSharedPdfConfigurableTool: Boolean
|
||||
get() = this != PdfInkTool.NONE
|
||||
|
||||
private fun SharedPdfReaderState.undoSharedPdfAnnotationEdit(): SharedPdfReaderState {
|
||||
val action = annotationUndoStack.lastOrNull() ?: return this
|
||||
val nextUndoStack = annotationUndoStack.dropLast(1)
|
||||
return when (action) {
|
||||
is SharedPdfAnnotationHistoryAction.Add -> copy(
|
||||
annotations = annotations.filterNot { it.id == action.annotation.id },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it != action.annotation.id },
|
||||
annotationUndoStack = nextUndoStack,
|
||||
annotationRedoStack = annotationRedoStack + action
|
||||
)
|
||||
|
||||
is SharedPdfAnnotationHistoryAction.Remove -> copy(
|
||||
annotations = annotations + action.itemsByPage.values.flatten(),
|
||||
annotationUndoStack = nextUndoStack,
|
||||
annotationRedoStack = annotationRedoStack + action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedPdfReaderState.redoSharedPdfAnnotationEdit(): SharedPdfReaderState {
|
||||
val action = annotationRedoStack.lastOrNull() ?: return this
|
||||
val nextRedoStack = annotationRedoStack.dropLast(1)
|
||||
return when (action) {
|
||||
is SharedPdfAnnotationHistoryAction.Add -> copy(
|
||||
annotations = annotations + action.annotation,
|
||||
selectedAnnotationId = action.annotation.id,
|
||||
annotationUndoStack = annotationUndoStack + action,
|
||||
annotationRedoStack = nextRedoStack
|
||||
)
|
||||
|
||||
is SharedPdfAnnotationHistoryAction.Remove -> {
|
||||
val removedIds = action.itemsByPage.values.flatten().mapTo(mutableSetOf()) { it.id }
|
||||
copy(
|
||||
annotations = annotations.filterNot { it.id in removedIds },
|
||||
selectedAnnotationId = selectedAnnotationId?.takeIf { it !in removedIds },
|
||||
annotationUndoStack = annotationUndoStack + action,
|
||||
annotationRedoStack = nextRedoStack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object SharedPdfSearchEngine {
|
||||
fun search(
|
||||
pageTexts: List<String>,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ object PdfSelectionGeometry {
|
|||
chars: List<PdfTextCharBounds>,
|
||||
lineTolerance: Float = DefaultCharLineTolerance
|
||||
): List<PdfPageBounds> {
|
||||
return chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
|
||||
return mergeBoundsByLine(
|
||||
bounds = chars.groupByLine(lineTolerance).map { it.toCharLineBounds() }
|
||||
)
|
||||
}
|
||||
|
||||
fun nearestCharOnLine(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ object PdfSpreadLayout {
|
|||
return listOf(start, start + 1).filter { it in 0 until pageCount }
|
||||
}
|
||||
|
||||
fun visiblePageIndicesForDisplay(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): List<Int> {
|
||||
val indices = visiblePageIndices(pageIndex, pageCount, settings)
|
||||
return if (settings.rightToLeftPagination) indices.asReversed() else indices
|
||||
}
|
||||
|
||||
fun spreadStartPageIndices(
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package com.aryan.reader.shared.pdf
|
|||
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private const val DEFAULT_PDF_COMMENT_AUTHOR = "Reader"
|
||||
|
||||
data class SharedPdfAnnotationExportPayload(
|
||||
val inkAnnotations: List<SharedPdfInkAnnotationExport> = emptyList(),
|
||||
val highlightAnnotations: List<SharedPdfHighlightAnnotationExport> = emptyList()
|
||||
|
|
@ -208,7 +206,7 @@ private fun List<SharedPdfHighlightCommentExport>.toSingleVisiblePdfCommentThrea
|
|||
SharedPdfHighlightCommentExport(
|
||||
id = "${highlightId}_comments",
|
||||
parentId = null,
|
||||
author = root.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
|
||||
author = root.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR },
|
||||
contents = threadContents,
|
||||
createdAt = createdAt,
|
||||
modifiedAt = modifiedAt
|
||||
|
|
@ -228,7 +226,7 @@ private fun List<SharedPdfHighlightCommentExport>.formatAsPdfCommentThread(): St
|
|||
|
||||
if (lines.isNotEmpty()) lines += ""
|
||||
val indent = " ".repeat(depth)
|
||||
val author = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
||||
val author = comment.author.ifBlank { DEFAULT_SHARED_PDF_COMMENT_AUTHOR }
|
||||
lines += "$indent$author:"
|
||||
comment.contents.lines().forEach { line ->
|
||||
lines += "$indent$line"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import kotlinx.serialization.json.longOrNull
|
|||
|
||||
object SharedPdfAnnotationSidecarCodec {
|
||||
const val KEY_PDF_ANNOTATIONS = "pdfAnnotations"
|
||||
const val KEY_PDF_ANNOTATION_DELETIONS = "pdfAnnotationDeletions"
|
||||
const val KEY_LEGACY_INK = "ink"
|
||||
const val KEY_LEGACY_TEXT_BOXES = "textBoxes"
|
||||
const val KEY_LEGACY_HIGHLIGHTS = "highlights"
|
||||
|
|
@ -38,16 +39,17 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
}
|
||||
|
||||
fun annotationsFromData(data: JsonObject): List<SharedPdfAnnotation> {
|
||||
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it) }
|
||||
val deletedIds = annotationDeletionsFromData(data).keys
|
||||
data[KEY_PDF_ANNOTATIONS]?.let { return decodeAnnotationsElement(it).filterNot { annotation -> annotation.id in deletedIds } }
|
||||
|
||||
data[KEY_LEGACY_INK]?.let { ink ->
|
||||
val decoded = decodeAnnotationsElement(ink)
|
||||
if (decoded.isNotEmpty() || ink.looksLikeSharedAnnotationStore()) {
|
||||
return decoded
|
||||
return decoded.filterNot { annotation -> annotation.id in deletedIds }
|
||||
}
|
||||
}
|
||||
|
||||
return legacyAndroidAnnotationsFromData(data)
|
||||
return legacyAndroidAnnotationsFromData(data).filterNot { annotation -> annotation.id in deletedIds }
|
||||
}
|
||||
|
||||
fun withCanonicalAnnotations(data: JsonObject): JsonObject {
|
||||
|
|
@ -62,6 +64,80 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
return json.encodeToString(JsonElement.serializer(), withCanonicalAnnotations(data))
|
||||
}
|
||||
|
||||
fun mergeAnnotationDataJson(
|
||||
localDataJson: String,
|
||||
remoteDataJson: String,
|
||||
preferRemoteOnConflict: Boolean
|
||||
): String {
|
||||
val localData = parseObjectOrNull(localDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap())
|
||||
val remoteData = parseObjectOrNull(remoteDataJson)?.sidecarDataObject() ?: JsonObject(emptyMap())
|
||||
val localCanonical = withCanonicalAnnotations(localData)
|
||||
val remoteCanonical = withCanonicalAnnotations(remoteData)
|
||||
val localAnnotations = annotationsFromData(localCanonical)
|
||||
val remoteAnnotations = annotationsFromData(remoteCanonical)
|
||||
val mergedDeletions = mergeAnnotationDeletions(
|
||||
annotationDeletionsFromData(localCanonical),
|
||||
annotationDeletionsFromData(remoteCanonical)
|
||||
)
|
||||
val mergedById = linkedMapOf<String, SharedPdfAnnotation>()
|
||||
val first = if (preferRemoteOnConflict) localAnnotations else remoteAnnotations
|
||||
val second = if (preferRemoteOnConflict) remoteAnnotations else localAnnotations
|
||||
first.forEach { annotation ->
|
||||
if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation
|
||||
}
|
||||
second.forEach { annotation ->
|
||||
if (annotation.id !in mergedDeletions) mergedById[annotation.id] = annotation
|
||||
}
|
||||
val base = (if (preferRemoteOnConflict) remoteCanonical else localCanonical).toMutableMap()
|
||||
base[KEY_PDF_ANNOTATIONS] = encodeAnnotationsElement(mergedById.values.toList().sortedForSync())
|
||||
if (mergedDeletions.isNotEmpty()) {
|
||||
base[KEY_PDF_ANNOTATION_DELETIONS] = encodeAnnotationDeletionsElement(mergedDeletions)
|
||||
} else {
|
||||
base.remove(KEY_PDF_ANNOTATION_DELETIONS)
|
||||
}
|
||||
return json.encodeToString(JsonElement.serializer(), JsonObject(base))
|
||||
}
|
||||
|
||||
fun annotationCountFromDataJson(rawDataJson: String): Int {
|
||||
val data = parseObjectOrNull(rawDataJson)?.sidecarDataObject() ?: return 0
|
||||
return annotationsFromData(withCanonicalAnnotations(data)).size
|
||||
}
|
||||
|
||||
fun annotationDeletionsFromData(data: JsonObject): Map<String, Long> {
|
||||
return data[KEY_PDF_ANNOTATION_DELETIONS].parseAnnotationDeletions()
|
||||
}
|
||||
|
||||
fun annotationDeletionsFromJson(rawJson: String): Map<String, Long> {
|
||||
val element = runCatching { json.parseToJsonElement(rawJson) }.getOrNull() ?: return emptyMap()
|
||||
return when (element) {
|
||||
is JsonObject -> {
|
||||
val data = element.sidecarDataObject()
|
||||
annotationDeletionsFromData(data).ifEmpty { element.parseAnnotationDeletions() }
|
||||
}
|
||||
else -> element.parseAnnotationDeletions()
|
||||
}
|
||||
}
|
||||
|
||||
fun annotationDeletionsJson(deletions: Map<String, Long>): String {
|
||||
return json.encodeToString(JsonElement.serializer(), encodeAnnotationDeletionsElement(deletions))
|
||||
}
|
||||
|
||||
fun encodeAnnotationDeletionsElement(deletions: Map<String, Long>): JsonElement {
|
||||
return JsonArray(
|
||||
deletions
|
||||
.filterKeys { it.isNotBlank() }
|
||||
.toSortedMap()
|
||||
.map { (id, deletedAt) ->
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(id),
|
||||
"deletedAt" to JsonPrimitive(deletedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun legacyAndroidDataFromAnnotations(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
existingData: JsonObject = JsonObject(emptyMap())
|
||||
|
|
@ -283,6 +359,47 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
return runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotation>.sortedForSync(): List<SharedPdfAnnotation> {
|
||||
return sortedWith(
|
||||
compareBy<SharedPdfAnnotation> { it.pageIndex }
|
||||
.thenBy { it.createdAt.takeIf { timestamp -> timestamp > 0L } ?: Long.MAX_VALUE }
|
||||
.thenBy { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergeAnnotationDeletions(
|
||||
local: Map<String, Long>,
|
||||
remote: Map<String, Long>
|
||||
): Map<String, Long> {
|
||||
if (local.isEmpty()) return remote
|
||||
if (remote.isEmpty()) return local
|
||||
return buildMap {
|
||||
(local.keys + remote.keys).forEach { id ->
|
||||
put(id, maxOf(local[id] ?: 0L, remote[id] ?: 0L))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.parseAnnotationDeletions(): Map<String, Long> {
|
||||
val element = this ?: return emptyMap()
|
||||
val array = element.jsonArrayOrNull()
|
||||
?: element.jsonObjectOrNull()?.array(KEY_PDF_ANNOTATION_DELETIONS)
|
||||
?: return emptyMap()
|
||||
return buildMap {
|
||||
array.forEach { item ->
|
||||
val primitiveId = item.jsonPrimitiveOrNull()?.contentOrNull
|
||||
val obj = item.jsonObjectOrNull()
|
||||
val id = primitiveId?.takeIf { it.isNotBlank() } ?: obj?.string("id")
|
||||
if (id.isNullOrBlank()) return@forEach
|
||||
val deletedAt = obj?.long("deletedAt")
|
||||
?: obj?.long("timestamp")
|
||||
?: obj?.long("ts")
|
||||
?: 0L
|
||||
put(id, maxOf(this[id] ?: 0L, deletedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableAnnotationId(prefix: String, element: JsonElement): String {
|
||||
return "${prefix}_${localFolderSyncSha256ShortHex(json.encodeToString(JsonElement.serializer(), element))}"
|
||||
}
|
||||
|
|
@ -314,6 +431,10 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
this[KEY_LEGACY_HIGHLIGHTS] != null
|
||||
}
|
||||
|
||||
private fun JsonObject.sidecarDataObject(): JsonObject {
|
||||
return this["data"]?.jsonObjectOrNull() ?: this
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonArrayOrNull(): JsonArray? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonArray }.getOrNull()
|
||||
|
|
@ -324,6 +445,11 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
return runCatching { jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonElement.jsonPrimitiveOrNull(): JsonPrimitive? {
|
||||
if (this is JsonNull) return null
|
||||
return runCatching { jsonPrimitive }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.array(name: String): JsonArray? = this[name]?.jsonArrayOrNull()
|
||||
|
||||
private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull()
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ const val SHARED_PDF_PAGE_BREAK_CHAR: Char = '\u000C'
|
|||
private const val SHARED_PDF_ZWSP = "\u200B"
|
||||
private const val SHARED_PDF_RICH_FONT_PATH_TAG = "pdf-rich-font-path"
|
||||
|
||||
internal fun sharedPdfRichTextSelectionBounds(
|
||||
selectionStart: Int,
|
||||
selectionEnd: Int,
|
||||
textLength: Int
|
||||
): Pair<Int, Int>? {
|
||||
val safeLength = textLength.coerceAtLeast(0)
|
||||
val localStart = minOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
|
||||
val localEnd = maxOf(selectionStart, selectionEnd).coerceIn(0, safeLength)
|
||||
return if (localStart < localEnd) localStart to localEnd else null
|
||||
}
|
||||
|
||||
object SharedPdfRichTextLog {
|
||||
var enabled: Boolean = true
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ package com.aryan.reader.shared.reader
|
|||
|
||||
import com.aryan.reader.paginatedreader.SemanticBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticFlexContainer
|
||||
import com.aryan.reader.paginatedreader.SemanticImage
|
||||
import com.aryan.reader.paginatedreader.SemanticList
|
||||
import com.aryan.reader.paginatedreader.SemanticMath
|
||||
import com.aryan.reader.paginatedreader.SemanticSpacer
|
||||
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
|
||||
import com.aryan.reader.shared.toStableReaderPositionCfi
|
||||
|
||||
sealed interface ReaderLinkTarget {
|
||||
data class External(val url: String) : ReaderLinkTarget
|
||||
|
|
@ -103,10 +107,10 @@ class ReaderEngine(
|
|||
highlights: List<UserHighlight> = emptyList()
|
||||
): ReaderSessionState {
|
||||
val pages = pagesFor(book, settings)
|
||||
val requestedInitialIndex = initialLocator
|
||||
val locatorResolvedIndex = initialLocator
|
||||
?.let { pages.findPageIndexForLocator(it) }
|
||||
?.takeIf { it >= 0 }
|
||||
?: initialPageIndex
|
||||
val requestedInitialIndex = locatorResolvedIndex ?: initialPageIndex
|
||||
val initialIndex = ReaderSpreadLayout.normalizePageIndex(requestedInitialIndex, pages.size, settings)
|
||||
val reader = PaginatedReaderState(
|
||||
book = book,
|
||||
|
|
@ -114,7 +118,13 @@ class ReaderEngine(
|
|||
currentPageIndex = initialIndex,
|
||||
settings = settings
|
||||
)
|
||||
return ReaderSessionState(
|
||||
logReaderPositionTrace {
|
||||
"event=engine_create_session_start book=\"${book.title.positionTracePreview(120)}\" " +
|
||||
"mode=${settings.readingMode} pages=${pages.size} initialPage=$initialPageIndex " +
|
||||
"locatorResolved=${locatorResolvedIndex ?: "null"} requested=$requestedInitialIndex normalized=$initialIndex " +
|
||||
"initialLocator=${initialLocator.positionTraceSummary()}"
|
||||
}
|
||||
val session = ReaderSessionState(
|
||||
reader = reader,
|
||||
bookmarks = bookmarks
|
||||
.mapNotNull { it.normalizedForBook(book, pages) }
|
||||
|
|
@ -128,6 +138,13 @@ class ReaderEngine(
|
|||
?.normalizedForResolvedPage(book, pages, requestedInitialIndex.coerceIn(0, pages.lastIndex.coerceAtLeast(0)))
|
||||
?: reader.currentPage?.toLocator(book)
|
||||
)
|
||||
logReaderPositionTrace {
|
||||
"event=engine_create_session_done book=\"${book.title.positionTracePreview(120)}\" " +
|
||||
"mode=${settings.readingMode} currentPage=${session.reader.currentPageIndex} " +
|
||||
"visiblePages=${session.reader.visiblePages.map { it.pageIndex }} " +
|
||||
"navigationLocator=${session.navigationLocator.positionTraceSummary()}"
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
fun next(state: ReaderSessionState): ReaderSessionState {
|
||||
|
|
@ -149,10 +166,17 @@ class ReaderEngine(
|
|||
fun goToPage(state: ReaderSessionState, pageIndex: Int): ReaderSessionState {
|
||||
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(target)
|
||||
val locator = page?.let {
|
||||
if (state.reader.settings.readingMode == ReaderReadingMode.VERTICAL) {
|
||||
it.toVerticalScrollPageLocator(state.reader.book)
|
||||
} else {
|
||||
it.toLocator(state.reader.book)
|
||||
}
|
||||
}
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == target },
|
||||
navigationLocator = page?.toLocator(state.reader.book),
|
||||
navigationLocator = locator,
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
}
|
||||
|
|
@ -173,15 +197,14 @@ class ReaderEngine(
|
|||
}
|
||||
|
||||
fun goToLocator(state: ReaderSessionState, locator: ReaderLocator): ReaderSessionState {
|
||||
val requestedPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
val requestedPageIndex = state.reader.pages.findPageIndexForLocator(locator)
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex
|
||||
?.takeIf { it in state.reader.pages.indices }
|
||||
?: return state
|
||||
val pageIndex = ReaderSpreadLayout.normalizePageIndex(requestedPageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(pageIndex) ?: return state
|
||||
val requestedPage = state.reader.pages.getOrNull(requestedPageIndex) ?: page
|
||||
val requestedChapter = state.reader.book.chapters.getOrNull(requestedPage.chapterIndex)
|
||||
val blockPosition = requestedPage.firstLocatorBlockPosition()
|
||||
val normalizedLocator = locator.copy(pageIndex = requestedPageIndex).withFallbacks(
|
||||
chapterIndex = requestedPage.chapterIndex,
|
||||
chapterId = requestedChapter?.id,
|
||||
|
|
@ -189,6 +212,8 @@ class ReaderEngine(
|
|||
pageIndex = requestedPageIndex,
|
||||
startOffset = requestedPage.startOffset,
|
||||
endOffset = requestedPage.endOffset,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = locator.textQuote ?: requestedPage.text.preview(),
|
||||
cfi = locator.cfi ?: requestedPage.toDesktopCfi()
|
||||
)
|
||||
|
|
@ -345,12 +370,26 @@ class ReaderEngine(
|
|||
fun syncVisiblePage(state: ReaderSessionState, pageIndex: Int, locator: ReaderLocator? = null): ReaderSessionState {
|
||||
val target = ReaderSpreadLayout.normalizePageIndex(pageIndex, state.reader.pages.size, state.reader.settings)
|
||||
val normalizedLocator = locator?.normalizedForPage(state, pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0)))
|
||||
if (target == state.reader.currentPageIndex && normalizedLocator == null) return state
|
||||
return state.copy(
|
||||
if (target == state.reader.currentPageIndex && normalizedLocator == null) {
|
||||
logReaderPositionTrace {
|
||||
"event=engine_sync_visible_skip reason=unchanged_no_locator mode=${state.reader.settings.readingMode} " +
|
||||
"inputPage=$pageIndex target=$target current=${state.reader.currentPageIndex}"
|
||||
}
|
||||
return state
|
||||
}
|
||||
val next = state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = target),
|
||||
activeSearchResultIndex = state.searchResults.indexOfFirst { it.pageIndex == pageIndex },
|
||||
navigationLocator = normalizedLocator ?: state.navigationLocator
|
||||
)
|
||||
logReaderPositionTrace {
|
||||
"event=engine_sync_visible_done mode=${state.reader.settings.readingMode} inputPage=$pageIndex " +
|
||||
"target=$target previousPage=${state.reader.currentPageIndex} nextPage=${next.reader.currentPageIndex} " +
|
||||
"inputLocator=${locator.positionTraceSummary()} normalizedLocator=${normalizedLocator.positionTraceSummary()} " +
|
||||
"previousNavigation=${state.navigationLocator.positionTraceSummary()} " +
|
||||
"nextNavigation=${next.navigationLocator.positionTraceSummary()}"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
fun updateSettings(state: ReaderSessionState, settings: ReaderSettings): ReaderSessionState {
|
||||
|
|
@ -509,13 +548,12 @@ class ReaderEngine(
|
|||
chapterTitle: String? = null,
|
||||
preview: String? = null
|
||||
): ReaderSessionState {
|
||||
val targetPageIndex = state.reader.pages.indexOfFirst { page -> page.contains(locator) }
|
||||
val targetPageIndex = state.reader.pages.findPageIndexForLocator(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 blockPosition = page.firstLocatorBlockPosition()
|
||||
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -523,8 +561,12 @@ class ReaderEngine(
|
|||
pageIndex = targetPageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = preview ?: page.text.preview(),
|
||||
cfi = locator.cfi ?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
|
||||
cfi = locator.cfi
|
||||
?: blockPosition?.androidStyleCfi()
|
||||
?: "desktop:${page.chapterIndex}:${locator.startOffset ?: page.startOffset}:${locator.endOffset ?: locator.startOffset ?: page.startOffset}"
|
||||
)
|
||||
val existing = state.bookmarks.firstOrNull {
|
||||
it.locator.sameLocation(normalizedLocator) ||
|
||||
|
|
@ -679,12 +721,13 @@ class ReaderEngine(
|
|||
if (state.searchResults.isEmpty()) return state
|
||||
val targetIndex = resultIndex.coerceIn(0, state.searchResults.lastIndex)
|
||||
val result = state.searchResults[targetIndex]
|
||||
val requestedPage = state.reader.pages.indexOfFirst { page -> page.contains(result.locator) }
|
||||
val requestedPage = state.reader.pages.findPageIndexForLocator(result.locator)
|
||||
.takeIf { it >= 0 }
|
||||
?: result.pageIndex.coerceIn(0, state.reader.pages.lastIndex.coerceAtLeast(0))
|
||||
val targetPage = ReaderSpreadLayout.normalizePageIndex(requestedPage, state.reader.pages.size, state.reader.settings)
|
||||
val page = state.reader.pages.getOrNull(targetPage)
|
||||
val chapter = page?.let { state.reader.book.chapters.getOrNull(it.chapterIndex) }
|
||||
val blockPosition = page?.firstLocatorBlockPosition()
|
||||
return state.copy(
|
||||
reader = state.reader.copy(currentPageIndex = targetPage),
|
||||
activeSearchResultIndex = targetIndex,
|
||||
|
|
@ -692,7 +735,9 @@ class ReaderEngine(
|
|||
chapterIndex = page?.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
href = chapter?.baseHref,
|
||||
pageIndex = requestedPage
|
||||
pageIndex = requestedPage,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset
|
||||
),
|
||||
navigationRequestId = state.navigationRequestId + 1
|
||||
)
|
||||
|
|
@ -731,7 +776,7 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
|||
val start = locator.startOffset ?: return false
|
||||
val end = locator.endOffset ?: start
|
||||
return if (start == end) {
|
||||
start in startOffset..endOffset
|
||||
containsCollapsedOffset(start)
|
||||
} else {
|
||||
start < endOffset && end > startOffset
|
||||
}
|
||||
|
|
@ -740,11 +785,128 @@ private fun ReaderPage.contains(locator: ReaderLocator): Boolean {
|
|||
return targetPage != null && targetPage == pageIndex
|
||||
}
|
||||
|
||||
private fun ReaderPage.containsCollapsedOffset(offset: Int): Boolean {
|
||||
return if (startOffset == endOffset) {
|
||||
offset == startOffset
|
||||
} else {
|
||||
offset >= startOffset && offset < endOffset
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.findPageIndexForLocator(locator: ReaderLocator): Int {
|
||||
return indexOfFirst { page -> page.contains(locator) }
|
||||
.takeIf { it >= 0 }
|
||||
?: locator.pageIndex?.takeIf { it in indices }
|
||||
?: -1
|
||||
if (locator.blockIndex != null) {
|
||||
val blockIndex = findPageIndexForBlockLocator(locator)
|
||||
if (blockIndex >= 0) return blockIndex
|
||||
}
|
||||
|
||||
if (locator.hasTextRange) {
|
||||
val textRangeIndex = indexOfFirst { page -> page.containsTextRange(locator) }
|
||||
if (textRangeIndex >= 0) return textRangeIndex
|
||||
|
||||
if (locator.startOffset == locator.endOffset) {
|
||||
val offset = locator.startOffset
|
||||
val targetChapter = locator.chapterIndex
|
||||
val finalBoundaryIndex = indexOfLast { page ->
|
||||
(targetChapter == null || targetChapter == page.chapterIndex) &&
|
||||
page.startOffset < page.endOffset &&
|
||||
page.endOffset == offset
|
||||
}
|
||||
if (finalBoundaryIndex >= 0) return finalBoundaryIndex
|
||||
}
|
||||
}
|
||||
|
||||
return locator.pageIndex?.takeIf { it in indices } ?: -1
|
||||
}
|
||||
|
||||
private fun ReaderPage.containsTextRange(locator: ReaderLocator): Boolean {
|
||||
val targetChapter = locator.chapterIndex
|
||||
if (targetChapter != null && targetChapter != chapterIndex) return false
|
||||
val start = locator.startOffset ?: return false
|
||||
val end = locator.endOffset ?: start
|
||||
return if (start == end) {
|
||||
containsCollapsedOffset(start)
|
||||
} else {
|
||||
start < endOffset && end > startOffset
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.findPageIndexForBlockLocator(locator: ReaderLocator): Int {
|
||||
val blockIndex = locator.blockIndex ?: return -1
|
||||
val charOffset = locator.charOffset
|
||||
val targetChapter = locator.chapterIndex
|
||||
var fallbackPageIndex = -1
|
||||
for ((pageIndex, page) in withIndex()) {
|
||||
if (targetChapter != null && page.chapterIndex != targetChapter) continue
|
||||
val blocks = page.semanticBlocks.flattenSemanticBlocks()
|
||||
if (fallbackPageIndex < 0 && blocks.any { it.blockIndex == blockIndex }) {
|
||||
fallbackPageIndex = pageIndex
|
||||
}
|
||||
if (charOffset == null) continue
|
||||
for (block in blocks.filterIsInstance<SemanticTextBlock>()) {
|
||||
if (block.blockIndex != blockIndex) continue
|
||||
val start = block.startCharOffsetInSource
|
||||
val end = start + block.text.length
|
||||
if (charOffset in start until end || (block.text.isEmpty() && charOffset == start)) {
|
||||
return pageIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallbackPageIndex
|
||||
}
|
||||
|
||||
private data class ReaderBlockPosition(
|
||||
val blockIndex: Int,
|
||||
val charOffset: Int,
|
||||
val cfi: String? = null,
|
||||
val localCharOffset: Int = 0
|
||||
) {
|
||||
fun androidStyleCfi(): String? {
|
||||
val base = cfi
|
||||
?.takeIf { it.startsWith("/") }
|
||||
?.substringBefore(':')
|
||||
?: return null
|
||||
return "$base:${localCharOffset.coerceAtLeast(0)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderPage.firstLocatorBlockPosition(): ReaderBlockPosition? {
|
||||
val blocks = semanticBlocks.flattenSemanticBlocks()
|
||||
val textBlock = blocks
|
||||
.filterIsInstance<SemanticTextBlock>()
|
||||
.firstOrNull { it.text.isNotBlank() }
|
||||
?: blocks.filterIsInstance<SemanticTextBlock>().firstOrNull()
|
||||
if (textBlock != null) {
|
||||
return ReaderBlockPosition(
|
||||
blockIndex = textBlock.blockIndex,
|
||||
charOffset = textBlock.startCharOffsetInSource,
|
||||
cfi = textBlock.cfi,
|
||||
localCharOffset = 0
|
||||
)
|
||||
}
|
||||
val firstBlock = blocks.firstOrNull() ?: return null
|
||||
return ReaderBlockPosition(
|
||||
blockIndex = firstBlock.blockIndex,
|
||||
charOffset = 0,
|
||||
cfi = firstBlock.cfi,
|
||||
localCharOffset = 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SemanticBlock>.flattenSemanticBlocks(): List<SemanticBlock> {
|
||||
return flatMap { it.flattenSemanticBlock() }
|
||||
}
|
||||
|
||||
private fun SemanticBlock.flattenSemanticBlock(): List<SemanticBlock> {
|
||||
return when (this) {
|
||||
is SemanticList -> listOf(this) + items
|
||||
is SemanticTable -> listOf(this) + rows.flatMap { row -> row.flatMap { cell -> cell.content.flattenSemanticBlocks() } }
|
||||
is SemanticFlexContainer -> listOf(this) + children.flattenSemanticBlocks()
|
||||
is SemanticWrappingBlock -> listOf(this, floatedImage) + paragraphsToWrap
|
||||
is SemanticImage,
|
||||
is SemanticMath,
|
||||
is SemanticSpacer,
|
||||
is SemanticTextBlock -> listOf(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderLocator.normalizedForResolvedPage(
|
||||
|
|
@ -756,6 +918,7 @@ private fun ReaderLocator.normalizedForResolvedPage(
|
|||
val chapter = book.chapters.getOrNull(page.chapterIndex)
|
||||
val start = startOffset ?: page.startOffset
|
||||
val end = (endOffset ?: start).coerceAtLeast(start)
|
||||
val blockPosition = page.firstLocatorBlockPosition()
|
||||
return copy(pageIndex = page.pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -763,18 +926,25 @@ private fun ReaderLocator.normalizedForResolvedPage(
|
|||
pageIndex = page.pageIndex,
|
||||
startOffset = start,
|
||||
endOffset = end,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = textQuote ?: page.text.preview(),
|
||||
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
cfi = cfi
|
||||
?.toStableReaderPositionCfi()
|
||||
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
|
||||
?: blockPosition?.androidStyleCfi()
|
||||
?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<ReaderPage>): ReaderBookmark? {
|
||||
val targetPageIndex = pages.indexOfFirst { page -> page.contains(locator) }
|
||||
val targetPageIndex = pages.findPageIndexForLocator(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 blockPosition = page.firstLocatorBlockPosition()
|
||||
val normalizedLocator = locator.copy(pageIndex = targetPageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -782,8 +952,10 @@ private fun ReaderBookmark.normalizedForBook(book: SharedEpubBook, pages: List<R
|
|||
pageIndex = targetPageIndex,
|
||||
startOffset = page.startOffset,
|
||||
endOffset = page.endOffset,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = preview.ifBlank { page.text.preview() },
|
||||
cfi = locator.cfi ?: page.toDesktopCfi()
|
||||
cfi = locator.cfi ?: blockPosition?.androidStyleCfi() ?: page.toDesktopCfi()
|
||||
)
|
||||
return copy(
|
||||
pageIndex = targetPageIndex,
|
||||
|
|
@ -800,6 +972,8 @@ private fun ReaderBookmark.locationKey(): String {
|
|||
locator.pageIndex,
|
||||
locator.startOffset,
|
||||
locator.endOffset,
|
||||
locator.blockIndex,
|
||||
locator.charOffset,
|
||||
locator.cfi
|
||||
).joinToString(":")
|
||||
}
|
||||
|
|
@ -808,7 +982,9 @@ private fun bookmarkId(bookId: String, pageIndex: Int, locator: ReaderLocator):
|
|||
val chapter = locator.chapterIndex ?: -1
|
||||
val start = locator.startOffset ?: -1
|
||||
val end = locator.endOffset ?: start
|
||||
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}"
|
||||
val block = locator.blockIndex ?: -1
|
||||
val char = locator.charOffset ?: -1
|
||||
return "${bookId}_${pageIndex}_${chapter}_${start}_${end}_${block}_${char}"
|
||||
}
|
||||
|
||||
private fun ReaderLocator.belongsTo(page: ReaderPage): Boolean {
|
||||
|
|
@ -832,6 +1008,7 @@ private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex
|
|||
val chapter = state.reader.book.chapters.getOrNull(page.chapterIndex)
|
||||
val start = startOffset ?: page.startOffset
|
||||
val end = (endOffset ?: start).coerceAtLeast(start)
|
||||
val blockPosition = page.firstLocatorBlockPosition()
|
||||
return copy(pageIndex = page.pageIndex).withFallbacks(
|
||||
chapterIndex = page.chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -839,13 +1016,20 @@ private fun ReaderLocator.normalizedForPage(state: ReaderSessionState, pageIndex
|
|||
pageIndex = page.pageIndex,
|
||||
startOffset = start,
|
||||
endOffset = end,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = textQuote ?: page.text.preview(),
|
||||
cfi = cfi ?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
cfi = cfi
|
||||
?.toStableReaderPositionCfi()
|
||||
?.takeUnless { it.startsWith("desktop-scroll:") || it.startsWith("desktop-scroll-page:") }
|
||||
?: blockPosition?.androidStyleCfi()
|
||||
?: "desktop:${page.chapterIndex}:$start:$end"
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
|
||||
val chapter = book.chapters.getOrNull(chapterIndex)
|
||||
val blockPosition = firstLocatorBlockPosition()
|
||||
return ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
chapterId = chapter?.id,
|
||||
|
|
@ -853,11 +1037,17 @@ private fun ReaderPage.toLocator(book: SharedEpubBook): ReaderLocator {
|
|||
pageIndex = pageIndex,
|
||||
startOffset = startOffset,
|
||||
endOffset = endOffset,
|
||||
blockIndex = blockPosition?.blockIndex,
|
||||
charOffset = blockPosition?.charOffset,
|
||||
textQuote = text.preview(),
|
||||
cfi = toDesktopCfi()
|
||||
cfi = blockPosition?.androidStyleCfi() ?: toDesktopCfi()
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderPage.toVerticalScrollPageLocator(book: SharedEpubBook): ReaderLocator {
|
||||
return toLocator(book).copy(cfi = "desktop-scroll-page:$pageIndex")
|
||||
}
|
||||
|
||||
private fun ReaderPage.toDesktopCfi(): String {
|
||||
return "desktop:$chapterIndex:$startOffset:$endOffset"
|
||||
}
|
||||
|
|
@ -1001,3 +1191,27 @@ private fun Char?.isWordChar(): Boolean {
|
|||
private fun logReaderLink(message: String) {
|
||||
logSharedReaderDiagnostic("ReaderLinkResolve") { message }
|
||||
}
|
||||
|
||||
private const val ReaderPositionTraceLogTag = "EpistemeDesktopPositionTrace"
|
||||
|
||||
private fun logReaderPositionTrace(message: () -> String) {
|
||||
logSharedReaderDiagnostic(ReaderPositionTraceLogTag, message)
|
||||
}
|
||||
|
||||
private fun ReaderLocator?.positionTraceSummary(maxTextLength: Int = 90): String {
|
||||
if (this == null) return "null"
|
||||
return "chapter=${chapterIndex ?: "null"} page=${pageIndex ?: "null"} " +
|
||||
"offsets=${startOffset ?: "null"}..${endOffset ?: "null"} " +
|
||||
"block=${blockIndex ?: "null"} char=${charOffset ?: "null"} " +
|
||||
"chapterId=\"${chapterId.orEmpty().positionTracePreview(80)}\" " +
|
||||
"href=\"${href.orEmpty().positionTracePreview(120)}\" " +
|
||||
"cfi=\"${cfi.orEmpty().positionTracePreview(180)}\" " +
|
||||
"text=\"${textQuote.orEmpty().positionTracePreview(maxTextLength)}\""
|
||||
}
|
||||
|
||||
private fun String.positionTracePreview(maxLength: Int = 96): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -110,6 +110,8 @@ private fun ReaderLocator.jumpLocationKey(): String {
|
|||
href.orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
blockIndex?.toString().orEmpty(),
|
||||
charOffset?.toString().orEmpty(),
|
||||
stableCfi
|
||||
).joinToString("|")
|
||||
}
|
||||
|
|
@ -120,6 +122,8 @@ private fun ReaderLocator.jumpLocationKey(): String {
|
|||
pageIndex?.toString().orEmpty(),
|
||||
startOffset?.toString().orEmpty(),
|
||||
endOffset?.toString().orEmpty(),
|
||||
blockIndex?.toString().orEmpty(),
|
||||
charOffset?.toString().orEmpty(),
|
||||
cfi.orEmpty()
|
||||
).joinToString("|")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ data class ReaderSettings(
|
|||
val pageInfoMode: PageInfoMode = PageInfoMode.DEFAULT,
|
||||
val pageInfoPosition: PageInfoPosition = PageInfoPosition.BOTTOM,
|
||||
val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE,
|
||||
val rightToLeftPagination: Boolean = false,
|
||||
val pdfVerticalPageGapVisible: Boolean = true,
|
||||
val pdfPageNumberOverlayVisible: Boolean = true,
|
||||
val pdfFirstPageStandaloneInSpread: Boolean = false,
|
||||
|
|
@ -171,7 +172,7 @@ data class PaginatedReaderState(
|
|||
val canGoNext: Boolean get() = ReaderSpreadLayout.canGoNext(currentPageIndex, pages.size, settings)
|
||||
val currentSpreadStartIndex: Int get() = ReaderSpreadLayout.normalizePageIndex(currentPageIndex, pages.size, settings)
|
||||
val visiblePages: List<ReaderPage>
|
||||
get() = ReaderSpreadLayout.visiblePageIndices(currentPageIndex, pages.size, settings)
|
||||
get() = ReaderSpreadLayout.visiblePageIndicesForDisplay(currentPageIndex, pages.size, settings)
|
||||
.mapNotNull { pages.getOrNull(it) }
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +212,11 @@ object ReaderSpreadLayout {
|
|||
return listOf(start, start + 1).filter { it in 0 until pageCount }
|
||||
}
|
||||
|
||||
fun visiblePageIndicesForDisplay(pageIndex: Int, pageCount: Int, settings: ReaderSettings): List<Int> {
|
||||
val indices = visiblePageIndices(pageIndex, pageCount, settings)
|
||||
return if (settings.isRightToLeftPaginationEnabled()) indices.asReversed() else indices
|
||||
}
|
||||
|
||||
fun pageRangeLabel(pageIndex: Int, pageCount: Int, settings: ReaderSettings): String {
|
||||
val total = pageCount.coerceAtLeast(1)
|
||||
val pages = visiblePageIndices(pageIndex, total, settings).ifEmpty { listOf(0) }
|
||||
|
|
@ -252,3 +258,7 @@ object ReaderSpreadLayout {
|
|||
fun ReaderSettings.isTwoPageSpreadEnabled(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED && pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE
|
||||
}
|
||||
|
||||
fun ReaderSettings.isRightToLeftPaginationEnabled(): Boolean {
|
||||
return readingMode == ReaderReadingMode.PAGINATED && rightToLeftPagination
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ package com.aryan.reader.shared.reader
|
|||
|
||||
internal const val SharedReaderDiagnosticsProperty = "episteme.desktop.diagnostics"
|
||||
internal const val SharedReaderDiagnosticsTagsProperty = "episteme.desktop.diagnostics.tags"
|
||||
internal const val SharedEpubCutoffDiagnosticsTag = "EpistemeEpubCutoff"
|
||||
|
||||
internal expect val SharedReaderDiagnosticsEnabled: Boolean
|
||||
internal expect fun isSharedReaderDiagnosticTagEnabled(tag: String): Boolean
|
||||
internal expect fun writeSharedReaderDiagnostic(tag: String, message: String)
|
||||
|
||||
internal inline fun logSharedReaderDiagnostic(tag: String, message: () -> String) {
|
||||
if (SharedReaderDiagnosticsEnabled && isSharedReaderDiagnosticTagEnabled(tag)) {
|
||||
println("$tag ${message()}")
|
||||
writeSharedReaderDiagnostic(tag, message())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,33 @@ enum class SharedAppToolAction {
|
|||
TABS_TOGGLE
|
||||
}
|
||||
|
||||
enum class SharedAppMoreGroup {
|
||||
LIBRARY,
|
||||
ACCOUNT,
|
||||
PREFERENCES,
|
||||
HELP
|
||||
}
|
||||
|
||||
data class SharedAppMoreSection(
|
||||
val group: SharedAppMoreGroup,
|
||||
val actions: List<SharedAppToolAction>
|
||||
)
|
||||
|
||||
data class SharedAppShellModel(
|
||||
val primaryTabs: List<SharedAppTab>,
|
||||
val primaryActions: List<SharedAppToolAction>,
|
||||
val selectedPrimaryTab: SharedAppTab,
|
||||
val toolActions: List<SharedAppToolAction>,
|
||||
val moreSections: List<SharedAppMoreSection>,
|
||||
val showPrimaryNavigation: Boolean
|
||||
)
|
||||
|
||||
data class SharedSidebarSyncToggleModel(
|
||||
val visible: Boolean,
|
||||
val enabled: Boolean,
|
||||
val checked: Boolean
|
||||
)
|
||||
|
||||
fun sharedAppShellModel(
|
||||
selectedTab: SharedAppTab,
|
||||
aiSettingsAvailable: Boolean,
|
||||
|
|
@ -43,12 +63,16 @@ fun sharedAppShellModel(
|
|||
val primaryTabs = buildList {
|
||||
add(SharedAppTab.LIBRARY)
|
||||
if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS)
|
||||
if (featurePolicy.aiAndCloud) add(SharedAppTab.PRO)
|
||||
}
|
||||
val primaryActions = buildList {
|
||||
if (aiSettingsAvailable && featurePolicy.aiAndCloud && !featurePolicy.byokAi) {
|
||||
add(SharedAppToolAction.AI_SETTINGS)
|
||||
}
|
||||
}
|
||||
val selectedPrimaryTab = when (selectedTab) {
|
||||
SharedAppTab.HOME -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SETTINGS,
|
||||
SharedAppTab.PRO,
|
||||
SharedAppTab.CUSTOM_FONTS,
|
||||
SharedAppTab.SUPPORT,
|
||||
SharedAppTab.FEEDBACK,
|
||||
|
|
@ -57,11 +81,7 @@ fun sharedAppShellModel(
|
|||
}.takeIf { it in primaryTabs } ?: SharedAppTab.LIBRARY
|
||||
val toolActions = buildList {
|
||||
add(SharedAppToolAction.SETTINGS)
|
||||
add(SharedAppToolAction.IMPORT_FILES)
|
||||
add(SharedAppToolAction.IMPORT_FOLDER)
|
||||
add(SharedAppToolAction.SYNC)
|
||||
add(SharedAppToolAction.APP_THEME)
|
||||
if (featurePolicy.aiAndCloud) add(SharedAppToolAction.PRO)
|
||||
if (aiSettingsAvailable && featurePolicy.aiAndCloud) add(SharedAppToolAction.AI_SETTINGS)
|
||||
add(SharedAppToolAction.CUSTOM_FONTS)
|
||||
if (featurePolicy.projectLinks) {
|
||||
|
|
@ -69,16 +89,58 @@ fun sharedAppShellModel(
|
|||
add(SharedAppToolAction.SUPPORT)
|
||||
}
|
||||
add(SharedAppToolAction.ABOUT)
|
||||
add(SharedAppToolAction.TABS_TOGGLE)
|
||||
}
|
||||
return SharedAppShellModel(
|
||||
primaryTabs = primaryTabs,
|
||||
primaryActions = primaryActions,
|
||||
selectedPrimaryTab = selectedPrimaryTab,
|
||||
toolActions = toolActions,
|
||||
moreSections = sharedAppMoreSections(toolActions),
|
||||
showPrimaryNavigation = selectedTab != SharedAppTab.READER
|
||||
)
|
||||
}
|
||||
|
||||
fun sharedAppMoreSections(actions: List<SharedAppToolAction>): List<SharedAppMoreSection> {
|
||||
return listOf(
|
||||
SharedAppMoreSection(
|
||||
group = SharedAppMoreGroup.PREFERENCES,
|
||||
actions = actions.filter {
|
||||
it == SharedAppToolAction.SETTINGS ||
|
||||
it == SharedAppToolAction.APP_THEME ||
|
||||
it == SharedAppToolAction.AI_SETTINGS ||
|
||||
it == SharedAppToolAction.CUSTOM_FONTS
|
||||
}
|
||||
),
|
||||
SharedAppMoreSection(
|
||||
group = SharedAppMoreGroup.HELP,
|
||||
actions = actions.filter {
|
||||
it == SharedAppToolAction.HELP_FEEDBACK ||
|
||||
it == SharedAppToolAction.SUPPORT ||
|
||||
it == SharedAppToolAction.ABOUT
|
||||
}
|
||||
)
|
||||
).filter { it.actions.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun sharedSidebarSyncToggleModel(
|
||||
isSignedIn: Boolean,
|
||||
accountAvailable: Boolean,
|
||||
syncAvailable: Boolean,
|
||||
isProUser: Boolean,
|
||||
isSyncEnabled: Boolean,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
|
||||
): SharedSidebarSyncToggleModel {
|
||||
val visible = isSignedIn &&
|
||||
accountAvailable &&
|
||||
syncAvailable &&
|
||||
featurePolicy.aiAndCloud
|
||||
return SharedSidebarSyncToggleModel(
|
||||
visible = visible,
|
||||
enabled = visible && isProUser,
|
||||
checked = visible && isSyncEnabled
|
||||
)
|
||||
}
|
||||
|
||||
data class NonReaderHomeLayoutModel(
|
||||
val continueBook: BookItem?,
|
||||
val activeTabs: List<BookItem>,
|
||||
|
|
@ -153,7 +215,7 @@ private val LibraryFileTypeGroupTemplates = listOf(
|
|||
NonReaderLibraryFileTypeGroup(
|
||||
titleKey = "desktop_file_type_group_comics",
|
||||
titleFallback = "Comics",
|
||||
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -166,9 +228,20 @@ private val AndroidLibraryTabs = listOf(
|
|||
private val DesktopLibraryTabs = listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS
|
||||
NonReaderLibraryTab.FOLDERS,
|
||||
NonReaderLibraryTab.UNREAD,
|
||||
NonReaderLibraryTab.IN_PROGRESS,
|
||||
NonReaderLibraryTab.COMPLETED
|
||||
)
|
||||
|
||||
internal enum class NonReaderLibraryPrimaryAction {
|
||||
NEW_SHELF
|
||||
}
|
||||
|
||||
internal enum class NonReaderBookOverflowAction {
|
||||
ADD_TO_SHELF
|
||||
}
|
||||
|
||||
internal fun visibleNonReaderLibraryTabs(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): List<NonReaderLibraryTab> {
|
||||
|
|
@ -178,6 +251,42 @@ internal fun visibleNonReaderLibraryTabs(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun primaryLibraryActionsForTab(
|
||||
tab: NonReaderLibraryTab,
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): List<NonReaderLibraryPrimaryAction> {
|
||||
return if (platform == ReaderPlatform.DESKTOP && tab.visibleLibraryTab(platform) == NonReaderLibraryTab.SHELVES) {
|
||||
listOf(NonReaderLibraryPrimaryAction.NEW_SHELF)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun bookOverflowActionsForPlatform(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): Set<NonReaderBookOverflowAction> {
|
||||
return when (platform) {
|
||||
ReaderPlatform.DESKTOP -> setOf(NonReaderBookOverflowAction.ADD_TO_SHELF)
|
||||
ReaderPlatform.ANDROID -> emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class LibraryCommandBarLayout {
|
||||
INLINE,
|
||||
STACKED
|
||||
}
|
||||
|
||||
internal fun libraryCommandBarLayoutForWidth(
|
||||
widthDp: Float,
|
||||
platform: ReaderPlatform = ReaderPlatform.DESKTOP
|
||||
): LibraryCommandBarLayout {
|
||||
return if (platform == ReaderPlatform.DESKTOP && widthDp >= 980f) {
|
||||
LibraryCommandBarLayout.INLINE
|
||||
} else {
|
||||
LibraryCommandBarLayout.STACKED
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NonReaderLibraryTab.visibleLibraryTab(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): NonReaderLibraryTab {
|
||||
|
|
@ -221,7 +330,7 @@ internal fun nonReaderLibraryFileTypeGroups(
|
|||
}
|
||||
|
||||
fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibraryOrganizationModel {
|
||||
val books = rawLibraryBooks
|
||||
val books = organizationBooks()
|
||||
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(
|
||||
|
|
@ -244,6 +353,13 @@ fun SharedReaderScreenState.toNonReaderLibraryOrganizationModel(): NonReaderLibr
|
|||
)
|
||||
}
|
||||
|
||||
private fun SharedReaderScreenState.organizationBooks(): List<BookItem> {
|
||||
if (shelves.isEmpty()) return rawLibraryBooks
|
||||
return shelves
|
||||
.flatMap { it.books }
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
|
||||
private fun LibraryFilters.activeFilterCount(): Int {
|
||||
return fileTypes.size +
|
||||
sourceFolders.size +
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ 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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -178,7 +176,18 @@ fun SharedHomeScreen(
|
|||
showActiveTabs: Boolean = true,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val model = state.toNonReaderHomeLayoutModel()
|
||||
val model = remember(
|
||||
state.recentBooks,
|
||||
state.openTabs,
|
||||
state.openTabIds,
|
||||
state.activeTabBookId,
|
||||
state.isTabsEnabled,
|
||||
state.pinnedHomeBookIds,
|
||||
state.selectedBookIds,
|
||||
state.rawLibraryBooks
|
||||
) {
|
||||
state.toNonReaderHomeLayoutModel()
|
||||
}
|
||||
NonReaderScreenScaffold(
|
||||
title = readerString("nav_home", "Home"),
|
||||
subtitle = readerString("desktop_home_subtitle", "Continue reading and recent books"),
|
||||
|
|
@ -323,12 +332,15 @@ fun SharedLibraryScreen(
|
|||
onShowBookInfo: (BookItem) -> Unit = {},
|
||||
onEditBook: (BookItem) -> Unit = {},
|
||||
onCreateShelf: () -> Unit = {},
|
||||
onCreateShelfWithBooks: (String, Set<String>) -> Unit = { _, _ -> },
|
||||
onCreateSmartShelf: () -> Unit = {},
|
||||
onRenameShelf: (Shelf) -> Unit = {},
|
||||
onDeleteShelf: (Shelf) -> Unit = {},
|
||||
onRemoveFolder: (Shelf) -> Unit = {},
|
||||
onTagSelectedBooks: () -> Unit = {},
|
||||
onAddSelectedBooksToShelf: () -> Unit = {},
|
||||
onAddBooksToShelf: (Set<String>) -> Unit = {},
|
||||
onManageShelfBooks: ((Shelf) -> Unit)? = null,
|
||||
onImportFolder: () -> Unit = {},
|
||||
onSyncFolderMetadata: () -> Unit = {},
|
||||
onScanFolders: () -> Unit = {},
|
||||
|
|
@ -337,7 +349,15 @@ fun SharedLibraryScreen(
|
|||
useImportEmptyStateWhenLibraryEmpty: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val organization = state.toNonReaderLibraryOrganizationModel()
|
||||
val organization = remember(
|
||||
state.rawLibraryBooks,
|
||||
state.shelves,
|
||||
state.allTags,
|
||||
state.syncedFolders,
|
||||
state.libraryFilters
|
||||
) {
|
||||
state.toNonReaderLibraryOrganizationModel()
|
||||
}
|
||||
val activeLibraryTab = selectedTab.visibleLibraryTab(platform)
|
||||
var showFilters by remember { mutableStateOf(false) }
|
||||
var viewMode by remember { mutableStateOf(BookViewMode.COVERS) }
|
||||
|
|
@ -399,8 +419,10 @@ fun SharedLibraryScreen(
|
|||
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
LibraryToolbar(
|
||||
state = state,
|
||||
selectedTab = activeLibraryTab,
|
||||
viewMode = viewMode,
|
||||
showFilters = showFilters,
|
||||
platform = platform,
|
||||
onViewModeChange = { viewMode = it },
|
||||
onToggleFilters = { showFilters = !showFilters },
|
||||
onStateChange = onStateChange,
|
||||
|
|
@ -419,11 +441,14 @@ fun SharedLibraryScreen(
|
|||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
useImportEmptyStateWhenLibraryEmpty = useImportEmptyStateWhenLibraryEmpty,
|
||||
onCreateShelf = onCreateShelf,
|
||||
onOpenBook = onOpenBook,
|
||||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
|
|
@ -443,8 +468,10 @@ fun SharedLibraryScreen(
|
|||
)
|
||||
LibraryToolbar(
|
||||
state = state,
|
||||
selectedTab = activeLibraryTab,
|
||||
viewMode = viewMode,
|
||||
showFilters = showFilters,
|
||||
platform = platform,
|
||||
onViewModeChange = { viewMode = it },
|
||||
onToggleFilters = { showFilters = !showFilters },
|
||||
onStateChange = onStateChange,
|
||||
|
|
@ -463,11 +490,14 @@ fun SharedLibraryScreen(
|
|||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
useImportEmptyStateWhenLibraryEmpty = useImportEmptyStateWhenLibraryEmpty,
|
||||
onCreateShelf = onCreateShelf,
|
||||
onOpenBook = onOpenBook,
|
||||
onToggleSelection = onToggleSelection,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
|
|
@ -904,8 +934,10 @@ private fun LibraryNavItem(
|
|||
@Composable
|
||||
private fun LibraryToolbar(
|
||||
state: SharedReaderScreenState,
|
||||
selectedTab: NonReaderLibraryTab,
|
||||
viewMode: BookViewMode,
|
||||
showFilters: Boolean,
|
||||
platform: ReaderPlatform,
|
||||
onViewModeChange: (BookViewMode) -> Unit,
|
||||
onToggleFilters: () -> Unit,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
|
|
@ -913,13 +945,129 @@ private fun LibraryToolbar(
|
|||
onImportFolder: () -> Unit,
|
||||
onCreateShelf: () -> Unit
|
||||
) {
|
||||
BoxWithConstraints {
|
||||
val layout = libraryCommandBarLayoutForWidth(maxWidth.value, platform)
|
||||
if (layout == LibraryCommandBarLayout.INLINE) {
|
||||
DesktopLibraryCommandBar(
|
||||
state = state,
|
||||
selectedTab = selectedTab,
|
||||
viewMode = viewMode,
|
||||
showFilters = showFilters,
|
||||
platform = platform,
|
||||
onViewModeChange = onViewModeChange,
|
||||
onToggleFilters = onToggleFilters,
|
||||
onStateChange = onStateChange,
|
||||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
onCreateShelf = onCreateShelf
|
||||
)
|
||||
} else {
|
||||
StackedLibraryCommandBar(
|
||||
state = state,
|
||||
selectedTab = selectedTab,
|
||||
viewMode = viewMode,
|
||||
showFilters = showFilters,
|
||||
platform = platform,
|
||||
onViewModeChange = onViewModeChange,
|
||||
onToggleFilters = onToggleFilters,
|
||||
onStateChange = onStateChange,
|
||||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
onCreateShelf = onCreateShelf
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopLibraryCommandBar(
|
||||
state: SharedReaderScreenState,
|
||||
selectedTab: NonReaderLibraryTab,
|
||||
viewMode: BookViewMode,
|
||||
showFilters: Boolean,
|
||||
platform: ReaderPlatform,
|
||||
onViewModeChange: (BookViewMode) -> Unit,
|
||||
onToggleFilters: () -> Unit,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
onImportBooks: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onCreateShelf: () -> Unit
|
||||
) {
|
||||
val showCreateShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
||||
primaryLibraryActionsForTab(selectedTab, platform)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(SharedUiTokens.surfaceRadius),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = sharedSubtleBorder(alpha = 0.5f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
LibrarySearchField(
|
||||
state = state,
|
||||
onStateChange = onStateChange,
|
||||
modifier = Modifier.weight(1f).widthIn(min = 260.dp)
|
||||
)
|
||||
SortMenu(
|
||||
sortOrder = state.sortOrder,
|
||||
onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }
|
||||
)
|
||||
LibraryFilterButton(
|
||||
filters = state.libraryFilters,
|
||||
showFilters = showFilters,
|
||||
onToggleFilters = onToggleFilters
|
||||
)
|
||||
Button(onClick = onImportBooks) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("desktop_import_files", "Import files"))
|
||||
}
|
||||
OutlinedButton(onClick = onImportFolder) {
|
||||
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_add_folder", "Add folder"))
|
||||
}
|
||||
if (showCreateShelfPrimaryAction) {
|
||||
Button(onClick = onCreateShelf) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
}
|
||||
LibraryMoreActionsMenu(
|
||||
viewMode = viewMode,
|
||||
onViewModeChange = onViewModeChange,
|
||||
onCreateShelf = onCreateShelf,
|
||||
showCreateShelfAction = !showCreateShelfPrimaryAction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StackedLibraryCommandBar(
|
||||
state: SharedReaderScreenState,
|
||||
selectedTab: NonReaderLibraryTab,
|
||||
viewMode: BookViewMode,
|
||||
showFilters: Boolean,
|
||||
platform: ReaderPlatform,
|
||||
onViewModeChange: (BookViewMode) -> Unit,
|
||||
onToggleFilters: () -> Unit,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
onImportBooks: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onCreateShelf: () -> Unit
|
||||
) {
|
||||
val showCreateShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
||||
primaryLibraryActionsForTab(selectedTab, platform)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
|
||||
singleLine = true,
|
||||
LibrarySearchField(
|
||||
state = state,
|
||||
onStateChange = onStateChange,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
|
|
@ -927,52 +1075,135 @@ private fun LibraryToolbar(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SortMenu(sortOrder = state.sortOrder, onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) })
|
||||
OutlinedButton(onClick = { onViewModeChange(if (viewMode == BookViewMode.COVERS) BookViewMode.LIST else BookViewMode.COVERS) }) {
|
||||
Icon(if (viewMode == BookViewMode.COVERS) Icons.AutoMirrored.Filled.List else Icons.Default.Book, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
SortMenu(
|
||||
sortOrder = state.sortOrder,
|
||||
onSortOrderChange = { onStateChange(state.reduce(LibraryAction.SortChanged(it))) }
|
||||
)
|
||||
LibraryFilterButton(
|
||||
filters = state.libraryFilters,
|
||||
showFilters = showFilters,
|
||||
onToggleFilters = onToggleFilters
|
||||
)
|
||||
Button(onClick = onImportBooks) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (viewMode == BookViewMode.COVERS) {
|
||||
readerString("desktop_list_view", "List")
|
||||
} else {
|
||||
readerString("desktop_cover_view", "Covers")
|
||||
}
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = onToggleFilters) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (showFilters) readerString("desktop_hide_filters", "Hide filters") else readerString("filter_library", "Filters"))
|
||||
if (state.libraryFilters.isActive) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Text(
|
||||
state.libraryFilters.activeFilterBadge(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onCreateShelf) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
Text(readerString("desktop_import_files", "Import files"))
|
||||
}
|
||||
OutlinedButton(onClick = onImportFolder) {
|
||||
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_add_folder", "Add folder"))
|
||||
}
|
||||
Button(onClick = onImportBooks) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("desktop_import_files", "Import files"))
|
||||
if (showCreateShelfPrimaryAction) {
|
||||
Button(onClick = onCreateShelf) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
}
|
||||
LibraryMoreActionsMenu(
|
||||
viewMode = viewMode,
|
||||
onViewModeChange = onViewModeChange,
|
||||
onCreateShelf = onCreateShelf,
|
||||
showCreateShelfAction = !showCreateShelfPrimaryAction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibrarySearchField(
|
||||
state: SharedReaderScreenState,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = { onStateChange(state.reduce(LibraryAction.SearchChanged(it))) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
|
||||
singleLine = true,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryFilterButton(
|
||||
filters: LibraryFilters,
|
||||
showFilters: Boolean,
|
||||
onToggleFilters: () -> Unit
|
||||
) {
|
||||
OutlinedButton(onClick = onToggleFilters) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (showFilters) readerString("desktop_hide_filters", "Hide filters") else readerString("filter_library", "Filters"))
|
||||
if (filters.isActive) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Text(
|
||||
filters.activeFilterBadge(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryMoreActionsMenu(
|
||||
viewMode: BookViewMode,
|
||||
onViewModeChange: (BookViewMode) -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
showCreateShelfAction: Boolean = true
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = readerString("desktop_more", "More"),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (viewMode == BookViewMode.COVERS) Icons.AutoMirrored.Filled.List else Icons.Default.Book,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
if (viewMode == BookViewMode.COVERS) {
|
||||
readerString("desktop_list_view", "List")
|
||||
} else {
|
||||
readerString("desktop_cover_view", "Covers")
|
||||
}
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onViewModeChange(
|
||||
if (viewMode == BookViewMode.COVERS) BookViewMode.LIST else BookViewMode.COVERS
|
||||
)
|
||||
}
|
||||
)
|
||||
if (showCreateShelfAction) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
|
||||
text = { Text(readerString("fab_new_shelf", "New shelf")) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onCreateShelf()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -990,11 +1221,14 @@ private fun LibraryContent(
|
|||
onImportBooks: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
useImportEmptyStateWhenLibraryEmpty: Boolean = false,
|
||||
onCreateShelf: () -> Unit,
|
||||
onOpenBook: (BookItem) -> Unit,
|
||||
onToggleSelection: (String) -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: (Set<String>) -> Unit,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)?,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
onDeleteShelf: (Shelf) -> Unit,
|
||||
onRemoveFolder: (Shelf) -> Unit,
|
||||
|
|
@ -1003,9 +1237,38 @@ private fun LibraryContent(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
val shelvesById = remember(state.shelves) { state.shelves.associateBy { it.id } }
|
||||
val visibleBooks = remember(state.libraryBooks, selectedTab, platform) {
|
||||
state.booksForNonReaderLibraryTab(selectedTab, platform)
|
||||
}
|
||||
val tagShelves = remember(state.shelves) {
|
||||
state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 }
|
||||
}
|
||||
val browseShelves = remember(state.shelves) {
|
||||
state.shelves.filter {
|
||||
it.type != ShelfType.FOLDER &&
|
||||
it.type != ShelfType.TAG &&
|
||||
it.type != ShelfType.SMART
|
||||
}
|
||||
}
|
||||
val smartShelves = remember(state.shelves) {
|
||||
state.shelves.filter { it.type == ShelfType.SMART }
|
||||
}
|
||||
val rootFolderShelves = remember(state.shelves) {
|
||||
state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null }
|
||||
}
|
||||
val addToShelfFromBookAction = if (NonReaderBookOverflowAction.ADD_TO_SHELF in bookOverflowActionsForPlatform(platform)) {
|
||||
onAddBooksToShelf
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val manageShelfBooksAction = if (platform == ReaderPlatform.DESKTOP) onManageShelfBooks else null
|
||||
val showNewShelfPrimaryAction = NonReaderLibraryPrimaryAction.NEW_SHELF in
|
||||
primaryLibraryActionsForTab(selectedTab, platform)
|
||||
if (showFilters) {
|
||||
LibraryFilterPanel(
|
||||
state = state,
|
||||
platform = platform,
|
||||
onStateChange = onStateChange
|
||||
)
|
||||
} else if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) {
|
||||
|
|
@ -1017,7 +1280,7 @@ private fun LibraryContent(
|
|||
NonReaderLibraryTab.UNREAD,
|
||||
NonReaderLibraryTab.IN_PROGRESS,
|
||||
NonReaderLibraryTab.COMPLETED -> {
|
||||
val books = state.booksForNonReaderLibraryTab(selectedTab, platform)
|
||||
val books = visibleBooks
|
||||
if (books.isEmpty()) {
|
||||
if (state.rawLibraryBooks.isEmpty() && useImportEmptyStateWhenLibraryEmpty) {
|
||||
LibraryImportEmptyState(
|
||||
|
|
@ -1052,13 +1315,13 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddToShelf = addToShelfFromBookAction?.let { addToShelf -> { book -> addToShelf(setOf(book.id)) } },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NonReaderLibraryTab.SHELVES -> {
|
||||
val tagShelves = state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 }
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
BrowseByTagRow(
|
||||
tagShelves = tagShelves,
|
||||
|
|
@ -1077,7 +1340,7 @@ private fun LibraryContent(
|
|||
}
|
||||
)
|
||||
ShelfCollection(
|
||||
shelves = state.shelves.filter { it.type != ShelfType.FOLDER && it.type != ShelfType.TAG && it.type != ShelfType.SMART },
|
||||
shelves = browseShelves,
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
onOpenBook = onOpenBook,
|
||||
|
|
@ -1085,18 +1348,22 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onManageShelfBooks = manageShelfBooksAction,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
onCreateShelf = if (showNewShelfPrimaryAction) onCreateShelf else null,
|
||||
emptyTitle = readerString("desktop_no_shelves_yet", "No shelves yet"),
|
||||
emptyBody = readerString("desktop_no_shelves_desc", "Manual shelves and series collections will appear here."),
|
||||
emptyActionLabel = if (showNewShelfPrimaryAction) readerString("fab_new_shelf", "New shelf") else null,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NonReaderLibraryTab.SMART_SHELVES -> ShelfCollection(
|
||||
shelves = state.shelves.filter { it.type == ShelfType.SMART },
|
||||
shelves = smartShelves,
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
onOpenBook = onOpenBook,
|
||||
|
|
@ -1104,6 +1371,7 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
emptyTitle = readerString("desktop_no_smart_shelves_yet", "No smart shelves yet"),
|
||||
|
|
@ -1112,7 +1380,7 @@ private fun LibraryContent(
|
|||
)
|
||||
|
||||
NonReaderLibraryTab.TAGS -> ShelfCollection(
|
||||
shelves = state.shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 },
|
||||
shelves = tagShelves,
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
onOpenBook = onOpenBook,
|
||||
|
|
@ -1120,6 +1388,7 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
emptyTitle = readerString("desktop_no_tags_yet", "No tags yet"),
|
||||
emptyBody = readerString("desktop_no_tags_desc", "Tags added to books will appear here."),
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -1127,12 +1396,12 @@ private fun LibraryContent(
|
|||
|
||||
NonReaderLibraryTab.FOLDERS -> {
|
||||
val currentFolder = state.viewingShelfId
|
||||
?.let { id -> state.shelves.firstOrNull { it.id == id && it.type == ShelfType.FOLDER } }
|
||||
?.let { id -> shelvesById[id]?.takeIf { it.type == ShelfType.FOLDER } }
|
||||
if (currentFolder != null) {
|
||||
FolderShelfDetail(
|
||||
shelf = currentFolder,
|
||||
childShelves = currentFolder.childShelfIds.mapNotNull { childId ->
|
||||
state.shelves.firstOrNull { it.id == childId }
|
||||
shelvesById[childId]
|
||||
},
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
|
|
@ -1141,6 +1410,7 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
|
||||
onBack = { onStateChange(state.copy(viewingShelfId = currentFolder.parentShelfId)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -1154,7 +1424,7 @@ private fun LibraryContent(
|
|||
)
|
||||
}
|
||||
ShelfCollection(
|
||||
shelves = state.shelves.filter { it.type == ShelfType.FOLDER && it.parentShelfId == null },
|
||||
shelves = rootFolderShelves,
|
||||
selectedBookIds = state.selectedBookIds,
|
||||
pinnedBookIds = state.pinnedLibraryBookIds,
|
||||
onOpenBook = onOpenBook,
|
||||
|
|
@ -1162,6 +1432,7 @@ private fun LibraryContent(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = addToShelfFromBookAction,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
onOpenShelf = { shelf -> onStateChange(state.copy(viewingShelfId = shelf.id)) },
|
||||
emptyTitle = readerString("desktop_no_folders_yet", "No folders yet"),
|
||||
|
|
@ -1257,9 +1528,9 @@ private fun LibraryFilterSummary(
|
|||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun LibraryFilterPanel(
|
||||
state: SharedReaderScreenState,
|
||||
platform: ReaderPlatform,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
|
|
@ -1267,7 +1538,7 @@ private fun LibraryFilterPanel(
|
|||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = sharedSubtleBorder()
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(SharedUiTokens.panelPadding), verticalArrangement = Arrangement.spacedBy(SharedUiTokens.contentGap)) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(readerString("filter_library", "Filters"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
if (state.libraryFilters.isActive || state.searchQuery.isNotBlank()) {
|
||||
|
|
@ -1278,30 +1549,20 @@ private fun LibraryFilterPanel(
|
|||
}
|
||||
|
||||
LibraryFilterSection(title = readerString("filter_file_type", "File type")) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
nonReaderLibraryFileTypeGroups().forEach { group ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
readerString(group.titleKey, group.titleFallback),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
group.fileTypes.forEach { type ->
|
||||
FilterChip(
|
||||
selected = type in state.libraryFilters.fileTypes,
|
||||
onClick = {
|
||||
val updated = state.libraryFilters.fileTypes.toggle(type)
|
||||
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated))))
|
||||
},
|
||||
label = { Text(SharedFileCapabilities.displayNameFor(type)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
nonReaderLibraryFileTypeGroups(platform).flatMap { it.fileTypes }.forEach { type ->
|
||||
FilterChip(
|
||||
selected = type in state.libraryFilters.fileTypes,
|
||||
onClick = {
|
||||
val updated = state.libraryFilters.fileTypes.toggle(type)
|
||||
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(fileTypes = updated))))
|
||||
},
|
||||
label = { Text(SharedFileCapabilities.displayNameFor(type)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1328,7 +1589,14 @@ private fun LibraryFilterPanel(
|
|||
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(sourceFolders = updated))))
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
label = { Text(folder.name) }
|
||||
label = {
|
||||
Text(
|
||||
folder.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 180.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1360,9 +1628,10 @@ private fun LibraryFilterPanel(
|
|||
|
||||
if (state.allTags.isNotEmpty()) {
|
||||
LibraryFilterSection(title = readerString("section_tags", "Tags")) {
|
||||
FlowRow(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
state.allTags.forEach { tag ->
|
||||
FilterChip(
|
||||
|
|
@ -1372,7 +1641,14 @@ private fun LibraryFilterPanel(
|
|||
onStateChange(state.reduce(LibraryAction.FiltersChanged(state.libraryFilters.copy(tagIds = updated))))
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
label = { Text(tag.name) }
|
||||
label = {
|
||||
Text(
|
||||
tag.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 160.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1387,11 +1663,11 @@ private fun LibraryFilterSection(
|
|||
title: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
content()
|
||||
|
|
@ -1414,6 +1690,7 @@ private fun BookGrid(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddToShelf: ((BookItem) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (viewMode == BookViewMode.LIST) {
|
||||
|
|
@ -1432,7 +1709,8 @@ private fun BookGrid(
|
|||
onToggleSelection = { onToggleSelection(book.id) },
|
||||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) }
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1454,7 +1732,8 @@ private fun BookGrid(
|
|||
onToggleSelection = { onToggleSelection(book.id) },
|
||||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) }
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onAddToShelf = onAddToShelf?.let { addToShelf -> { addToShelf(book) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1473,6 +1752,7 @@ private fun BookTile(
|
|||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onAddToShelf: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
|
@ -1522,7 +1802,8 @@ private fun BookTile(
|
|||
onTogglePinned = onTogglePinned,
|
||||
onShowInfo = onShowInfo,
|
||||
onEdit = onEdit,
|
||||
onToggleSelection = onToggleSelection
|
||||
onToggleSelection = onToggleSelection,
|
||||
onAddToShelf = onAddToShelf
|
||||
)
|
||||
}
|
||||
TypeBadge(book.type, modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp))
|
||||
|
|
@ -1557,7 +1838,8 @@ private fun BookListItem(
|
|||
onToggleSelection: () -> Unit,
|
||||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onTogglePinned: () -> Unit
|
||||
onTogglePinned: () -> Unit,
|
||||
onAddToShelf: (() -> Unit)? = null
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
|
|
@ -1598,7 +1880,8 @@ private fun BookListItem(
|
|||
onTogglePinned = onTogglePinned,
|
||||
onShowInfo = onShowInfo,
|
||||
onEdit = onEdit,
|
||||
onToggleSelection = onToggleSelection
|
||||
onToggleSelection = onToggleSelection,
|
||||
onAddToShelf = onAddToShelf
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1614,7 +1897,8 @@ private fun BookActionMenu(
|
|||
onTogglePinned: () -> Unit,
|
||||
onShowInfo: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onToggleSelection: () -> Unit
|
||||
onToggleSelection: () -> Unit,
|
||||
onAddToShelf: (() -> Unit)? = null
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
DropdownMenuItem(
|
||||
|
|
@ -1641,6 +1925,16 @@ private fun BookActionMenu(
|
|||
onEdit()
|
||||
}
|
||||
)
|
||||
if (onAddToShelf != null) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) },
|
||||
text = { Text(readerString("desktop_add_to_shelf", "Add to shelf")) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onAddToShelf()
|
||||
}
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(if (selected) Icons.Default.Check else Icons.AutoMirrored.Filled.List, contentDescription = null) },
|
||||
text = { Text(if (selected) readerString("clear_selection", "Clear selection") else readerString("action_select", "Select")) },
|
||||
|
|
@ -1818,12 +2112,16 @@ private fun ShelfCollection(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)? = null,
|
||||
onRenameShelf: (Shelf) -> Unit = {},
|
||||
onDeleteShelf: (Shelf) -> Unit = {},
|
||||
onRemoveFolder: (Shelf) -> Unit = {},
|
||||
onOpenShelf: ((Shelf) -> Unit)? = null,
|
||||
onCreateShelf: (() -> Unit)? = null,
|
||||
emptyTitle: String,
|
||||
emptyBody: String,
|
||||
emptyActionLabel: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (shelves.isEmpty()) {
|
||||
|
|
@ -1831,6 +2129,8 @@ private fun ShelfCollection(
|
|||
icon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(56.dp)) },
|
||||
title = emptyTitle,
|
||||
body = emptyBody,
|
||||
actionLabel = emptyActionLabel,
|
||||
onAction = onCreateShelf,
|
||||
modifier = modifier
|
||||
)
|
||||
return
|
||||
|
|
@ -1851,6 +2151,8 @@ private fun ShelfCollection(
|
|||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onAddBooksToShelf = onAddBooksToShelf,
|
||||
onManageShelfBooks = onManageShelfBooks,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
|
|
@ -1870,6 +2172,8 @@ private fun ShelfSection(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)?,
|
||||
onManageShelfBooks: ((Shelf) -> Unit)?,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
onDeleteShelf: (Shelf) -> Unit,
|
||||
onRemoveFolder: (Shelf) -> Unit,
|
||||
|
|
@ -1912,6 +2216,23 @@ private fun ShelfSection(
|
|||
}
|
||||
}
|
||||
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") {
|
||||
if (onManageShelfBooks != null) {
|
||||
OutlinedButton(onClick = { onManageShelfBooks(shelf) }) {
|
||||
Icon(
|
||||
if (shelf.bookCount == 0) Icons.Default.Add else Icons.Default.FormatListNumbered,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (shelf.bookCount == 0) {
|
||||
readerString("fab_add_books", "Add books")
|
||||
} else {
|
||||
readerString("desktop_manage_books", "Manage books")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onRenameShelf(shelf) }, modifier = Modifier.size(34.dp)) {
|
||||
Icon(Icons.Default.Edit, contentDescription = readerString("menu_rename_shelf", "Rename shelf"), modifier = Modifier.size(18.dp))
|
||||
}
|
||||
|
|
@ -1937,6 +2258,7 @@ private fun ShelfSection(
|
|||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } },
|
||||
modifier = Modifier.width(148.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -1957,6 +2279,7 @@ private fun FolderShelfDetail(
|
|||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onAddBooksToShelf: ((Set<String>) -> Unit)? = null,
|
||||
onOpenShelf: (Shelf) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
|
|
@ -2021,7 +2344,8 @@ private fun FolderShelfDetail(
|
|||
onToggleSelection = { onToggleSelection(book.id) },
|
||||
onShowInfo = { onShowBookInfo(book) },
|
||||
onEdit = { onEditBook(book) },
|
||||
onTogglePinned = { onTogglePinned(book) }
|
||||
onTogglePinned = { onTogglePinned(book) },
|
||||
onAddToShelf = onAddBooksToShelf?.let { addToShelf -> { addToShelf(setOf(book.id)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -2431,7 +2755,7 @@ private fun fileTypeColor(type: FileType): Color {
|
|||
FileType.PDF -> Color(0xFF9C4146)
|
||||
FileType.EPUB, FileType.MOBI -> Color(0xFF006C4C)
|
||||
FileType.DOCX, FileType.ODT, FileType.FODT, FileType.PPTX -> Color(0xFF0F52BA)
|
||||
FileType.CBZ, FileType.CBR, FileType.CB7 -> Color(0xFF705D49)
|
||||
FileType.CBZ, FileType.CBR, FileType.CB7, FileType.CBT -> Color(0xFF705D49)
|
||||
else -> Color(0xFF5D6B82)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderHighlightPalette
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
|
|
@ -14,7 +13,6 @@ data class ReaderContentNavigationTarget(
|
|||
val locator: ReaderLocator?,
|
||||
val requestId: Long,
|
||||
val readingMode: ReaderReadingMode,
|
||||
val autoScroll: ReaderAutoScrollState = ReaderAutoScrollState(),
|
||||
val ttsLocator: ReaderLocator? = null,
|
||||
val ttsRequestId: Long = 0L
|
||||
)
|
||||
|
|
@ -28,6 +26,7 @@ sealed interface ReaderContentRenderPlan {
|
|||
data class WebDocument(
|
||||
val html: String,
|
||||
val appearanceScript: String,
|
||||
val highlightPaletteScript: String,
|
||||
override val background: Color,
|
||||
override val foreground: Color,
|
||||
override val navigationTarget: ReaderContentNavigationTarget,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -17,6 +18,7 @@ import androidx.compose.ui.geometry.CornerRadius
|
|||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -32,9 +34,12 @@ fun ReaderMinimalSlider(
|
|||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
activeColor: Color? = null,
|
||||
inactiveColor: Color? = null,
|
||||
thumbColor: Color? = null
|
||||
thumbColor: Color? = null,
|
||||
markerValue: Float? = null,
|
||||
markerColor: Color? = null
|
||||
) {
|
||||
var widthPx by remember { mutableFloatStateOf(0f) }
|
||||
var dragValue by remember { mutableStateOf<Float?>(null) }
|
||||
val rangeStart = valueRange.start
|
||||
val rangeEnd = valueRange.endInclusive
|
||||
|
||||
|
|
@ -45,22 +50,36 @@ fun ReaderMinimalSlider(
|
|||
}
|
||||
|
||||
val inputModifier = if (enabled) {
|
||||
Modifier.pointerInput(rangeStart, rangeEnd, widthPx) {
|
||||
Modifier.pointerInput(rangeStart, rangeEnd) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
onValueChangeStarted?.invoke()
|
||||
onValueChange(valueForOffset(down.position.x))
|
||||
down.consume()
|
||||
var gestureStarted = false
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
onValueChange(valueForOffset(change.position.x))
|
||||
change.consume()
|
||||
fun updateValue(offsetX: Float) {
|
||||
val nextValue = valueForOffset(offsetX)
|
||||
dragValue = nextValue
|
||||
onValueChange(nextValue)
|
||||
}
|
||||
|
||||
onValueChangeFinished?.invoke()
|
||||
try {
|
||||
onValueChangeStarted?.invoke()
|
||||
gestureStarted = true
|
||||
updateValue(down.position.x)
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
updateValue(change.position.x)
|
||||
change.consume()
|
||||
}
|
||||
} finally {
|
||||
if (gestureStarted) {
|
||||
onValueChangeFinished?.invoke()
|
||||
}
|
||||
dragValue = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -70,6 +89,8 @@ fun ReaderMinimalSlider(
|
|||
val effectiveActiveColor = activeColor ?: MaterialTheme.colorScheme.primary
|
||||
val effectiveInactiveColor = inactiveColor ?: MaterialTheme.colorScheme.surfaceVariant
|
||||
val effectiveThumbColor = thumbColor ?: MaterialTheme.colorScheme.primary
|
||||
val effectiveMarkerColor = markerColor ?: effectiveActiveColor
|
||||
val markerFraction = readerMinimalSliderMarkerFraction(markerValue, valueRange)
|
||||
val disabledAlpha = if (enabled) 1f else 0.38f
|
||||
|
||||
Box(
|
||||
|
|
@ -80,8 +101,9 @@ fun ReaderMinimalSlider(
|
|||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val range = rangeEnd - rangeStart
|
||||
val displayValue = dragValue ?: value
|
||||
val fraction = if (range > 0f) {
|
||||
((value.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
|
||||
((displayValue.coerceIn(rangeStart, rangeEnd) - rangeStart) / range).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
|
@ -104,6 +126,22 @@ fun ReaderMinimalSlider(
|
|||
cornerRadius = cornerRadius
|
||||
)
|
||||
|
||||
markerFraction?.let { fraction ->
|
||||
val markerWidth = 2.dp.toPx()
|
||||
val markerHeight = 12.dp.toPx()
|
||||
val markerX = if (size.width <= markerWidth) {
|
||||
size.width / 2f
|
||||
} else {
|
||||
(size.width * fraction).coerceIn(markerWidth / 2f, size.width - markerWidth / 2f)
|
||||
}
|
||||
drawRoundRect(
|
||||
color = effectiveMarkerColor.copy(alpha = effectiveMarkerColor.alpha * disabledAlpha),
|
||||
topLeft = Offset(markerX - markerWidth / 2f, centerY - markerHeight / 2f),
|
||||
size = Size(markerWidth, markerHeight),
|
||||
cornerRadius = CornerRadius(markerWidth / 2f, markerWidth / 2f)
|
||||
)
|
||||
}
|
||||
|
||||
val thumbCenterX = if (size.width <= thumbRadius * 2f) {
|
||||
size.width / 2f
|
||||
} else {
|
||||
|
|
@ -117,3 +155,16 @@ fun ReaderMinimalSlider(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readerMinimalSliderMarkerFraction(
|
||||
markerValue: Float?,
|
||||
valueRange: ClosedFloatingPointRange<Float>
|
||||
): Float? {
|
||||
val marker = markerValue ?: return null
|
||||
val rangeStart = valueRange.start
|
||||
val rangeEnd = valueRange.endInclusive
|
||||
if (rangeEnd <= rangeStart) return null
|
||||
|
||||
return ((marker.coerceIn(rangeStart, rangeEnd) - rangeStart) / (rangeEnd - rangeStart))
|
||||
.coerceIn(0f, 1f)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun ReaderTooltipIconButton(
|
||||
tooltip: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
val density = LocalDensity.current
|
||||
val marginPx = with(density) { 8.dp.roundToPx() }
|
||||
val offsetPx = with(density) { 8.dp.roundToPx() }
|
||||
|
||||
LaunchedEffect(hovered, tooltip) {
|
||||
if (hovered && tooltip.isNotBlank()) {
|
||||
delay(450L)
|
||||
visible = hovered
|
||||
} else {
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.pointerInput(tooltip) {
|
||||
awaitPointerEventScope {
|
||||
var isHovered = false
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
when (event.type) {
|
||||
PointerEventType.Enter,
|
||||
PointerEventType.Move -> if (!isHovered) {
|
||||
isHovered = true
|
||||
hovered = true
|
||||
}
|
||||
PointerEventType.Exit,
|
||||
PointerEventType.Press -> if (isHovered) {
|
||||
isHovered = false
|
||||
hovered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onClick, modifier = modifier, enabled = enabled) {
|
||||
content.invoke()
|
||||
}
|
||||
if (visible) {
|
||||
Popup(
|
||||
popupPositionProvider = ReaderTooltipPositionProvider(
|
||||
marginPx = marginPx,
|
||||
offsetPx = offsetPx
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Text(
|
||||
text = tooltip,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 260.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ReaderTooltipPositionProvider(
|
||||
private val marginPx: Int,
|
||||
private val offsetPx: Int
|
||||
) : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize
|
||||
): IntOffset {
|
||||
val centeredX = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
|
||||
val maxX = (windowSize.width - popupContentSize.width - marginPx).coerceAtLeast(marginPx)
|
||||
val x = centeredX.coerceIn(marginPx, maxX)
|
||||
val belowY = anchorBounds.bottom + offsetPx
|
||||
val aboveY = anchorBounds.top - popupContentSize.height - offsetPx
|
||||
val y = if (belowY + popupContentSize.height <= windowSize.height - marginPx) {
|
||||
belowY
|
||||
} else {
|
||||
aboveY.coerceAtLeast(marginPx)
|
||||
}
|
||||
return IntOffset(x, y)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,6 @@ enum class ReaderWorkspaceTopAction {
|
|||
APPEARANCE,
|
||||
READ_ALOUD,
|
||||
AI,
|
||||
AUTO_SCROLL,
|
||||
TOOLS
|
||||
}
|
||||
|
||||
|
|
@ -52,9 +51,47 @@ enum class ReaderWorkspaceBottomAction {
|
|||
data class ReaderWorkspaceChromeModel(
|
||||
val preferAutoHide: Boolean,
|
||||
val forceVisible: Boolean,
|
||||
val forceVisibleReasons: Set<String> = emptySet()
|
||||
val forceVisibleReasons: Set<String> = emptySet(),
|
||||
val revealVisibleReasons: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
internal fun readerWorkspaceChromeVisible(
|
||||
requestedVisible: Boolean,
|
||||
lockedVisible: Boolean,
|
||||
forcedVisible: Boolean
|
||||
): Boolean {
|
||||
return lockedVisible || forcedVisible || requestedVisible
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible: Boolean,
|
||||
lockedVisible: Boolean,
|
||||
forcedVisible: Boolean,
|
||||
rightPanelClosedByTap: Boolean = false
|
||||
): Boolean {
|
||||
return if (lockedVisible || forcedVisible || rightPanelClosedByTap) {
|
||||
true
|
||||
} else {
|
||||
!requestedVisible
|
||||
}
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen: Boolean,
|
||||
hasInspectorSections: Boolean,
|
||||
closeRightPanelOnReaderTap: Boolean
|
||||
): Boolean {
|
||||
return closeRightPanelOnReaderTap && rightPanelOpen && hasInspectorSections
|
||||
}
|
||||
|
||||
internal fun readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen: Boolean,
|
||||
chromeVisible: Boolean,
|
||||
hasNavigationSections: Boolean
|
||||
): Boolean {
|
||||
return toggledOpen && chromeVisible && hasNavigationSections
|
||||
}
|
||||
|
||||
data class ReaderWorkspaceFileActionState(
|
||||
val canShare: Boolean = false,
|
||||
val canSaveCopy: Boolean = false,
|
||||
|
|
@ -94,7 +131,8 @@ fun epubReaderWorkspaceModel(
|
|||
extrasState: ReaderExtrasState,
|
||||
aiAvailable: Boolean,
|
||||
cloudTtsAvailable: Boolean = true,
|
||||
externalLookupAvailable: Boolean = true
|
||||
externalLookupAvailable: Boolean = true,
|
||||
appThemeControlsAvailable: Boolean = false
|
||||
): ReaderWorkspaceModel {
|
||||
val preferences = toolbarPreferences.sanitized()
|
||||
val leftSections = listOf(
|
||||
|
|
@ -104,16 +142,21 @@ fun epubReaderWorkspaceModel(
|
|||
ReaderWorkspaceLeftSection.IMAGES
|
||||
)
|
||||
val inspectorSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) {
|
||||
if (
|
||||
appThemeControlsAvailable ||
|
||||
preferences.isVisible(ReaderTool.THEME) ||
|
||||
preferences.isVisible(ReaderTool.FORMAT) ||
|
||||
preferences.isVisible(ReaderTool.VISUAL_OPTIONS) ||
|
||||
preferences.isVisible(ReaderTool.READING_MODE)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.APPEARANCE)
|
||||
}
|
||||
if (preferences.isVisible(ReaderTool.READING_MODE)) {
|
||||
add(ReaderWorkspaceInspectorSection.TOOLS)
|
||||
}
|
||||
if (
|
||||
(aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) ||
|
||||
(cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) ||
|
||||
preferences.isVisible(ReaderTool.AUTO_SCROLL)
|
||||
(cloudTtsAvailable && (
|
||||
preferences.isVisible(ReaderTool.TTS_CONTROLS) ||
|
||||
preferences.isVisible(ReaderTool.TTS_SETTINGS)
|
||||
)) ||
|
||||
preferences.isVisible(ReaderTool.TTS_REPLACEMENTS)
|
||||
) {
|
||||
add(ReaderWorkspaceInspectorSection.AI_TTS)
|
||||
}
|
||||
|
|
@ -126,7 +169,6 @@ fun epubReaderWorkspaceModel(
|
|||
if (ReaderWorkspaceInspectorSection.APPEARANCE in inspectorSections) add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (cloudTtsAvailable && preferences.isVisible(ReaderTool.TTS_CONTROLS)) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable && preferences.isVisible(ReaderTool.AI_FEATURES)) add(ReaderWorkspaceTopAction.AI)
|
||||
if (preferences.isVisible(ReaderTool.AUTO_SCROLL)) add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
if (inspectorSections.isNotEmpty()) add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}.distinct()
|
||||
val bottomActions = buildList {
|
||||
|
|
@ -149,7 +191,7 @@ fun epubReaderWorkspaceModel(
|
|||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
autoScroll = extrasState.autoScroll,
|
||||
autoScroll = ReaderAutoScrollState(),
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
|
|
@ -211,7 +253,6 @@ fun pdfReaderWorkspaceModel(
|
|||
add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (cloudTtsAvailable) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
if (aiAvailable) add(ReaderWorkspaceTopAction.AI)
|
||||
add(ReaderWorkspaceTopAction.AUTO_SCROLL)
|
||||
add(ReaderWorkspaceTopAction.TOOLS)
|
||||
}
|
||||
return ReaderWorkspaceModel(
|
||||
|
|
@ -234,7 +275,7 @@ fun pdfReaderWorkspaceModel(
|
|||
richTextEditing = richTextEditing,
|
||||
loading = loading,
|
||||
errorMessage = errorMessage,
|
||||
autoScroll = extrasState.autoScroll,
|
||||
autoScroll = ReaderAutoScrollState(),
|
||||
ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
)
|
||||
)
|
||||
|
|
@ -252,7 +293,7 @@ fun readerWorkspaceChromeModel(
|
|||
autoScroll: ReaderAutoScrollState,
|
||||
ttsBusy: Boolean
|
||||
): ReaderWorkspaceChromeModel {
|
||||
val reasons = buildSet {
|
||||
val forceReasons = buildSet {
|
||||
if (searchActive) add("search")
|
||||
if (leftPanelOpen) add("left-panel")
|
||||
if (inspectorOpen) add("inspector")
|
||||
|
|
@ -261,11 +302,14 @@ fun readerWorkspaceChromeModel(
|
|||
if (loading) add("loading")
|
||||
if (!errorMessage.isNullOrBlank()) add("error")
|
||||
if (autoScroll.sanitized().enabled) add("auto-scroll")
|
||||
}
|
||||
val revealReasons = buildSet {
|
||||
if (ttsBusy) add("tts")
|
||||
}
|
||||
return ReaderWorkspaceChromeModel(
|
||||
preferAutoHide = preferAutoHide,
|
||||
forceVisible = reasons.isNotEmpty(),
|
||||
forceVisibleReasons = reasons
|
||||
forceVisible = forceReasons.isNotEmpty(),
|
||||
forceVisibleReasons = forceReasons,
|
||||
revealVisibleReasons = revealReasons
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,59 +1,54 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.CircleShape
|
||||
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.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Feedback
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
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.Lock
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
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.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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
|
||||
|
|
@ -70,9 +65,9 @@ import com.aryan.reader.shared.AppContrastOption
|
|||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.CustomAppTheme
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import com.aryan.reader.shared.UserData
|
||||
|
||||
enum class SharedAppTab {
|
||||
HOME,
|
||||
LIBRARY,
|
||||
SHELVES,
|
||||
CATALOGS,
|
||||
|
|
@ -97,6 +92,15 @@ fun SharedAppShell(
|
|||
customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
isTabsEnabled: Boolean = true,
|
||||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard,
|
||||
currentUser: UserData? = null,
|
||||
accountAvailable: Boolean = featurePolicy.aiAndCloud,
|
||||
isOssBuild: Boolean = false,
|
||||
isProUser: Boolean = false,
|
||||
isSyncEnabled: Boolean = false,
|
||||
syncAvailable: Boolean = featurePolicy.aiAndCloud,
|
||||
onSignInRequested: (() -> Unit)? = null,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)? = null,
|
||||
onSyncEnabledChange: (Boolean) -> Unit = {},
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit = {},
|
||||
|
|
@ -121,7 +125,23 @@ fun SharedAppShell(
|
|||
featurePolicy = featurePolicy
|
||||
)
|
||||
}
|
||||
var showToolsPanel by remember { mutableStateOf(false) }
|
||||
val sidebarSyncToggleModel = remember(
|
||||
currentUser != null,
|
||||
accountAvailable,
|
||||
syncAvailable,
|
||||
isProUser,
|
||||
isSyncEnabled,
|
||||
featurePolicy
|
||||
) {
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = currentUser != null,
|
||||
accountAvailable = accountAvailable,
|
||||
syncAvailable = syncAvailable,
|
||||
isProUser = isProUser,
|
||||
isSyncEnabled = isSyncEnabled,
|
||||
featurePolicy = featurePolicy
|
||||
)
|
||||
}
|
||||
var showAppThemeSettings by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -141,15 +161,65 @@ fun SharedAppShell(
|
|||
SharedAppSidebar(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
primaryActions = shellModel.primaryActions,
|
||||
currentUser = currentUser,
|
||||
accountAvailable = accountAvailable,
|
||||
isOssBuild = isOssBuild,
|
||||
syncToggleModel = sidebarSyncToggleModel,
|
||||
onAccountClick = { onTabSelected(SharedAppTab.PRO) },
|
||||
onSignInRequested = onSignInRequested,
|
||||
accountAvatar = accountAvatar,
|
||||
onSyncEnabledChange = onSyncEnabledChange,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
onPrimaryAction = { action ->
|
||||
when (action) {
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested?.invoke()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
moreMenu = {
|
||||
SharedMoreMenuButton(
|
||||
compact = false,
|
||||
moreSections = shellModel.moreSections,
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
onImportFiles = onImportFiles,
|
||||
onImportFolder = onImportFolder,
|
||||
onSyncRequested = onSyncRequested,
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested,
|
||||
onAppThemeRequested = { showAppThemeSettings = true },
|
||||
onAiSettingsRequested = { onAiSettingsRequested?.invoke() },
|
||||
onOpenTab = onTabSelected,
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
primaryActions = shellModel.primaryActions,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
onPrimaryAction = { action ->
|
||||
when (action) {
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested?.invoke()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
moreMenu = {
|
||||
SharedMoreMenuButton(
|
||||
compact = true,
|
||||
moreSections = shellModel.moreSections,
|
||||
isTabsEnabled = isTabsEnabled,
|
||||
onImportFiles = onImportFiles,
|
||||
onImportFolder = onImportFolder,
|
||||
onSyncRequested = onSyncRequested,
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested,
|
||||
onAppThemeRequested = { showAppThemeSettings = true },
|
||||
onAiSettingsRequested = { onAiSettingsRequested?.invoke() },
|
||||
onOpenTab = onTabSelected,
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -163,55 +233,6 @@ fun SharedAppShell(
|
|||
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,
|
||||
toolActions = shellModel.toolActions,
|
||||
onClose = { showToolsPanel = false },
|
||||
onImportFiles = {
|
||||
showToolsPanel = false
|
||||
onImportFiles()
|
||||
},
|
||||
onImportFolder = {
|
||||
showToolsPanel = false
|
||||
onImportFolder()
|
||||
},
|
||||
onSyncRequested = {
|
||||
showToolsPanel = false
|
||||
onSyncRequested()
|
||||
},
|
||||
onFolderMetadataSyncRequested = onFolderMetadataSyncRequested?.let { syncMetadata ->
|
||||
{
|
||||
showToolsPanel = false
|
||||
syncMetadata()
|
||||
}
|
||||
},
|
||||
onAppThemeRequested = {
|
||||
showToolsPanel = false
|
||||
showAppThemeSettings = true
|
||||
},
|
||||
onAiSettingsRequested = {
|
||||
showToolsPanel = false
|
||||
onAiSettingsRequested?.invoke()
|
||||
},
|
||||
onOpenTab = { tab ->
|
||||
showToolsPanel = false
|
||||
onTabSelected(tab)
|
||||
},
|
||||
onTabsEnabledChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,8 +260,18 @@ fun SharedAppShell(
|
|||
private fun SharedAppSidebar(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
primaryActions: List<SharedAppToolAction>,
|
||||
currentUser: UserData?,
|
||||
accountAvailable: Boolean,
|
||||
isOssBuild: Boolean,
|
||||
syncToggleModel: SharedSidebarSyncToggleModel,
|
||||
onAccountClick: () -> Unit,
|
||||
onSignInRequested: (() -> Unit)?,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)?,
|
||||
onSyncEnabledChange: (Boolean) -> Unit,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
onPrimaryAction: (SharedAppToolAction) -> Unit,
|
||||
moreMenu: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
|
|
@ -255,24 +286,48 @@ private fun SharedAppSidebar(
|
|||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(SharedUiTokens.compactGap)
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
|
||||
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("desktop_library_and_reader", "Library and reader"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
SharedSidebarHeader(
|
||||
currentUser = currentUser,
|
||||
accountAvailable = accountAvailable,
|
||||
isOssBuild = isOssBuild,
|
||||
onAccountClick = onAccountClick,
|
||||
onSignInRequested = onSignInRequested,
|
||||
accountAvatar = accountAvatar
|
||||
)
|
||||
primaryTabs.forEach { tab ->
|
||||
if (tab == SharedAppTab.PRO) {
|
||||
primaryActions.forEach { action ->
|
||||
SharedSidebarButton(
|
||||
label = action.labelForPrimaryNavigation(),
|
||||
icon = action.iconForPrimaryNavigation(),
|
||||
onClick = { onPrimaryAction(action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSidebarNavItem(
|
||||
tab = tab,
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) }
|
||||
)
|
||||
if (tab == SharedAppTab.PRO && syncToggleModel.visible) {
|
||||
SharedSidebarSyncToggle(
|
||||
model = syncToggleModel,
|
||||
onSyncEnabledChange = onSyncEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
if (SharedAppTab.PRO !in primaryTabs) {
|
||||
primaryActions.forEach { action ->
|
||||
SharedSidebarButton(
|
||||
label = action.labelForPrimaryNavigation(),
|
||||
icon = action.iconForPrimaryNavigation(),
|
||||
onClick = { onPrimaryAction(action) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
HorizontalDivider()
|
||||
SharedSidebarButton(
|
||||
label = readerString("desktop_tools", "Tools"),
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onToolsClick
|
||||
)
|
||||
moreMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -281,11 +336,23 @@ private fun SharedAppSidebar(
|
|||
private fun SharedAppCompactRail(
|
||||
selectedTab: SharedAppTab,
|
||||
primaryTabs: List<SharedAppTab>,
|
||||
primaryActions: List<SharedAppToolAction>,
|
||||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
onPrimaryAction: (SharedAppToolAction) -> Unit,
|
||||
moreMenu: @Composable () -> Unit
|
||||
) {
|
||||
NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) {
|
||||
primaryTabs.forEach { tab ->
|
||||
if (tab == SharedAppTab.PRO) {
|
||||
primaryActions.forEach { action ->
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = { onPrimaryAction(action) },
|
||||
icon = { Icon(action.iconForPrimaryNavigation(), contentDescription = null) },
|
||||
label = { Text(action.labelForPrimaryNavigation()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) },
|
||||
|
|
@ -293,13 +360,167 @@ private fun SharedAppCompactRail(
|
|||
label = { Text(tab.localizedLabel()) }
|
||||
)
|
||||
}
|
||||
if (SharedAppTab.PRO !in primaryTabs) {
|
||||
primaryActions.forEach { action ->
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = { onPrimaryAction(action) },
|
||||
icon = { Icon(action.iconForPrimaryNavigation(), contentDescription = null) },
|
||||
label = { Text(action.labelForPrimaryNavigation()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onToolsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = readerString("desktop_tools", "Tools"))
|
||||
moreMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarHeader(
|
||||
currentUser: UserData?,
|
||||
accountAvailable: Boolean,
|
||||
isOssBuild: Boolean,
|
||||
onAccountClick: () -> Unit,
|
||||
onSignInRequested: (() -> Unit)?,
|
||||
accountAvatar: (@Composable (UserData, Modifier) -> Unit)?
|
||||
) {
|
||||
val signInRequested = onSignInRequested
|
||||
val avatarContent = accountAvatar
|
||||
when {
|
||||
isOssBuild -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SharedInitialAvatar(initial = "E", modifier = Modifier.size(42.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
readerString("desktop_app_name_oss", "Episteme oss"),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
readerString("desktop_offline_oss_reader", "Offline desktop reader"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
currentUser != null -> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)),
|
||||
onClick = onAccountClick
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (avatarContent != null) {
|
||||
avatarContent(currentUser, Modifier.size(42.dp))
|
||||
} else {
|
||||
SharedInitialAvatar(initial = currentUser.initial(), modifier = Modifier.size(42.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
currentUser.displayName ?: currentUser.email ?: readerString("desktop_signed_in", "Signed in"),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
currentUser.email ?: readerString("desktop_account_and_credits", "Account & credits"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
accountAvailable && signInRequested != null -> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)),
|
||||
onClick = signInRequested
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AccountCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(42.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
readerString("drawer_sign_in", "Sign in with Google"),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
readerString("desktop_sign_in_account_header_desc", "Sync account, Pro, and credits"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
|
||||
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("desktop_library_and_reader", "Library and reader"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedInitialAvatar(
|
||||
initial: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserData.initial(): String {
|
||||
return (displayName ?: email ?: "E")
|
||||
.trim()
|
||||
.firstOrNull()
|
||||
?.uppercase()
|
||||
?: "E"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarNavItem(
|
||||
tab: SharedAppTab,
|
||||
|
|
@ -334,6 +555,68 @@ private fun SharedSidebarNavItem(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarSyncToggle(
|
||||
model: SharedSidebarSyncToggleModel,
|
||||
onSyncEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
val title = readerString("desktop_cloud_sync", "Cloud sync")
|
||||
val summary = when {
|
||||
!model.enabled -> readerString("desktop_pro_required", "Pro required")
|
||||
model.checked -> readerString("content_desc_enabled", "Enabled")
|
||||
else -> readerString("desktop_disabled", "Disabled")
|
||||
}
|
||||
val contentColor = if (model.enabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.62f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = contentColor,
|
||||
onClick = {
|
||||
if (model.enabled) {
|
||||
onSyncEnabledChange(!model.checked)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
summary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = model.checked,
|
||||
enabled = model.enabled,
|
||||
onCheckedChange = { checked ->
|
||||
if (model.enabled) {
|
||||
onSyncEnabledChange(checked)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedSidebarButton(
|
||||
label: String,
|
||||
|
|
@ -359,11 +642,10 @@ private fun SharedSidebarButton(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolsPanel(
|
||||
modifier: Modifier,
|
||||
private fun SharedMoreMenuButton(
|
||||
compact: Boolean,
|
||||
moreSections: List<SharedAppMoreSection>,
|
||||
isTabsEnabled: Boolean,
|
||||
toolActions: List<SharedAppToolAction>,
|
||||
onClose: () -> Unit,
|
||||
onImportFiles: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncRequested: () -> Unit,
|
||||
|
|
@ -373,190 +655,178 @@ private fun SharedToolsPanel(
|
|||
onOpenTab: (SharedAppTab) -> Unit,
|
||||
onTabsEnabledChange: (Boolean) -> Unit
|
||||
) {
|
||||
val hasWorkspaceActions = SharedAppToolAction.SETTINGS in toolActions ||
|
||||
SharedAppToolAction.APP_THEME in toolActions ||
|
||||
SharedAppToolAction.TABS_TOGGLE in toolActions
|
||||
val hasLibraryActions = SharedAppToolAction.IMPORT_FILES in toolActions ||
|
||||
SharedAppToolAction.IMPORT_FOLDER in toolActions ||
|
||||
SharedAppToolAction.SYNC in toolActions
|
||||
val hasSettingsActions = SharedAppToolAction.PRO in toolActions ||
|
||||
SharedAppToolAction.AI_SETTINGS in toolActions ||
|
||||
SharedAppToolAction.CUSTOM_FONTS in toolActions
|
||||
val hasProjectActions = SharedAppToolAction.HELP_FEEDBACK in toolActions ||
|
||||
SharedAppToolAction.SUPPORT in toolActions ||
|
||||
SharedAppToolAction.ABOUT in toolActions
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
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(readerString("desktop_tools", "Tools"), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("desktop_tools_desc", "Import, sync, and app settings"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = readerString("desktop_close_tools", "Close tools"))
|
||||
}
|
||||
fun runAction(action: SharedAppToolAction) {
|
||||
expanded = false
|
||||
when (action) {
|
||||
SharedAppToolAction.SETTINGS -> onOpenTab(SharedAppTab.SETTINGS)
|
||||
SharedAppToolAction.IMPORT_FILES -> onImportFiles()
|
||||
SharedAppToolAction.IMPORT_FOLDER -> onImportFolder()
|
||||
SharedAppToolAction.SYNC -> onSyncRequested()
|
||||
SharedAppToolAction.APP_THEME -> onAppThemeRequested()
|
||||
SharedAppToolAction.PRO -> onOpenTab(SharedAppTab.PRO)
|
||||
SharedAppToolAction.AI_SETTINGS -> onAiSettingsRequested()
|
||||
SharedAppToolAction.CUSTOM_FONTS -> onOpenTab(SharedAppTab.CUSTOM_FONTS)
|
||||
SharedAppToolAction.HELP_FEEDBACK -> onOpenTab(SharedAppTab.FEEDBACK)
|
||||
SharedAppToolAction.SUPPORT -> onOpenTab(SharedAppTab.SUPPORT)
|
||||
SharedAppToolAction.ABOUT -> onOpenTab(SharedAppTab.ABOUT)
|
||||
SharedAppToolAction.TABS_TOGGLE -> onTabsEnabledChange(!isTabsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = if (compact) Modifier else Modifier.fillMaxWidth()) {
|
||||
if (compact) {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = readerString("desktop_more_menu", "More"))
|
||||
}
|
||||
|
||||
if (hasWorkspaceActions) {
|
||||
SharedToolsSection(readerString("desktop_workspace", "Workspace")) {
|
||||
if (SharedAppToolAction.SETTINGS in toolActions) {
|
||||
SharedToolRow(Icons.Default.Settings, readerString("desktop_settings_hub", "Settings hub")) { onOpenTab(SharedAppTab.SETTINGS) }
|
||||
}
|
||||
if (SharedAppToolAction.APP_THEME in toolActions) {
|
||||
SharedToolRow(
|
||||
icon = Icons.Default.Palette,
|
||||
title = readerString("app_theme_title", "App theme"),
|
||||
onClick = onAppThemeRequested
|
||||
} else {
|
||||
SharedSidebarButton(
|
||||
label = readerString("desktop_more_menu", "More"),
|
||||
icon = Icons.Default.MoreVert,
|
||||
onClick = { expanded = true }
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.width(300.dp)
|
||||
) {
|
||||
moreSections.forEachIndexed { sectionIndex, section ->
|
||||
SharedMoreMenuSectionLabel(section.group)
|
||||
section.actions.forEach { action ->
|
||||
if (action == SharedAppToolAction.SYNC && onFolderMetadataSyncRequested != null) {
|
||||
SharedMoreMenuItem(
|
||||
icon = Icons.Default.Sync,
|
||||
title = readerString("desktop_sync_metadata", "Sync metadata"),
|
||||
onClick = {
|
||||
expanded = false
|
||||
onFolderMetadataSyncRequested()
|
||||
}
|
||||
)
|
||||
SharedMoreMenuItem(
|
||||
icon = Icons.Default.Search,
|
||||
title = readerString("desktop_full_scan", "Full scan"),
|
||||
onClick = { runAction(action) }
|
||||
)
|
||||
} else {
|
||||
SharedMoreMenuItem(
|
||||
icon = action.iconForMoreMenu(),
|
||||
title = action.labelForMoreMenu(isTabsEnabled),
|
||||
checked = if (action == SharedAppToolAction.TABS_TOGGLE) isTabsEnabled else null,
|
||||
onClick = { runAction(action) }
|
||||
)
|
||||
}
|
||||
if (SharedAppToolAction.TABS_TOGGLE in toolActions) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(readerString("desktop_open_readers", "Open readers"), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
if (isTabsEnabled) readerString("content_desc_enabled", "Enabled") else readerString("desktop_disabled", "Disabled"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isTabsEnabled,
|
||||
onCheckedChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sectionIndex != moreSections.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLibraryActions) {
|
||||
SharedToolsSection(readerString("library_title", "Library")) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
if (SharedAppToolAction.IMPORT_FILES in toolActions) {
|
||||
Button(onClick = onImportFiles, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.ImportExport, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("desktop_import_files", "Import files"))
|
||||
}
|
||||
}
|
||||
if (SharedAppToolAction.IMPORT_FOLDER in toolActions) {
|
||||
OutlinedButton(onClick = onImportFolder, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.CreateNewFolder, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("fab_add_folder", "Add folder"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (SharedAppToolAction.SYNC in toolActions) {
|
||||
if (onFolderMetadataSyncRequested == null) {
|
||||
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("desktop_sync_folders", "Sync folders"))
|
||||
}
|
||||
} else {
|
||||
SharedToolRow(Icons.Default.Sync, readerString("desktop_sync_metadata", "Sync metadata"), onFolderMetadataSyncRequested)
|
||||
SharedToolRow(Icons.Default.Search, readerString("desktop_full_scan", "Full scan")) {
|
||||
onSyncRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSettingsActions) {
|
||||
SharedToolsSection(readerString("settings", "Settings")) {
|
||||
if (SharedAppToolAction.PRO in toolActions) {
|
||||
SharedToolRow(Icons.Default.Star, readerString("desktop_pro_and_credits", "Pro and credits")) { onOpenTab(SharedAppTab.PRO) }
|
||||
}
|
||||
if (SharedAppToolAction.AI_SETTINGS in toolActions) {
|
||||
SharedToolRow(Icons.Default.Settings, readerString("ai_settings_title", "AI keys and models"), onAiSettingsRequested)
|
||||
}
|
||||
if (SharedAppToolAction.CUSTOM_FONTS in toolActions) {
|
||||
SharedToolRow(Icons.Default.TextFields, readerString("custom_fonts", "Custom fonts")) { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasProjectActions) {
|
||||
SharedToolsSection(readerString("desktop_project", "Project")) {
|
||||
if (SharedAppToolAction.HELP_FEEDBACK in toolActions) {
|
||||
SharedToolRow(Icons.Default.Feedback, readerString("drawer_help_feedback", "Help & feedback")) { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
}
|
||||
if (SharedAppToolAction.SUPPORT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Favorite, readerString("drawer_support_project", "Support project")) { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
}
|
||||
if (SharedAppToolAction.ABOUT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Info, readerString("about_title", "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()
|
||||
}
|
||||
private fun SharedMoreMenuSectionLabel(group: SharedAppMoreGroup) {
|
||||
Text(
|
||||
group.labelForMoreMenu(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedToolRow(
|
||||
private fun SharedMoreMenuItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
checked: Boolean? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
DropdownMenuItem(
|
||||
text = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
leadingIcon = { Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
trailingIcon = checked?.let { isChecked ->
|
||||
{ Switch(checked = isChecked, onCheckedChange = null) }
|
||||
},
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppToolAction.labelForPrimaryNavigation(): String {
|
||||
return when (this) {
|
||||
SharedAppToolAction.AI_SETTINGS -> readerString("desktop_ai_keys", "AI keys")
|
||||
else -> labelForMoreMenu(isTabsEnabled = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedAppToolAction.iconForPrimaryNavigation(): ImageVector {
|
||||
return when (this) {
|
||||
SharedAppToolAction.AI_SETTINGS -> Icons.Default.Lock
|
||||
else -> iconForMoreMenu()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppMoreGroup.labelForMoreMenu(): String {
|
||||
return when (this) {
|
||||
SharedAppMoreGroup.LIBRARY -> readerString("desktop_more_library_actions", "Library actions")
|
||||
SharedAppMoreGroup.ACCOUNT -> readerString("desktop_account_and_credits", "Account & credits")
|
||||
SharedAppMoreGroup.PREFERENCES -> readerString("desktop_preferences", "Preferences")
|
||||
SharedAppMoreGroup.HELP -> readerString("desktop_help", "Help")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppToolAction.labelForMoreMenu(isTabsEnabled: Boolean): String {
|
||||
return when (this) {
|
||||
SharedAppToolAction.SETTINGS -> readerString("settings", "Settings")
|
||||
SharedAppToolAction.IMPORT_FILES -> readerString("desktop_import_files", "Import files")
|
||||
SharedAppToolAction.IMPORT_FOLDER -> readerString("fab_add_folder", "Add folder")
|
||||
SharedAppToolAction.SYNC -> readerString("desktop_sync_folders", "Sync folders")
|
||||
SharedAppToolAction.APP_THEME -> readerString("app_theme_title", "App theme")
|
||||
SharedAppToolAction.PRO -> readerString("desktop_account_and_credits", "Account & credits")
|
||||
SharedAppToolAction.AI_SETTINGS -> readerString("ai_settings_title", "AI keys and models")
|
||||
SharedAppToolAction.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
|
||||
SharedAppToolAction.HELP_FEEDBACK -> readerString("drawer_help_feedback", "Help & feedback")
|
||||
SharedAppToolAction.SUPPORT -> readerString("drawer_support_project", "Support project")
|
||||
SharedAppToolAction.ABOUT -> readerString("about_title", "About Episteme")
|
||||
SharedAppToolAction.TABS_TOGGLE -> if (isTabsEnabled) {
|
||||
readerString("desktop_reader_tabs_on", "Reader tabs on")
|
||||
} else {
|
||||
readerString("desktop_reader_tabs_off", "Reader tabs off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedAppToolAction.iconForMoreMenu(): ImageVector {
|
||||
return when (this) {
|
||||
SharedAppToolAction.SETTINGS -> Icons.Default.Settings
|
||||
SharedAppToolAction.IMPORT_FILES -> Icons.Default.ImportExport
|
||||
SharedAppToolAction.IMPORT_FOLDER -> Icons.Default.CreateNewFolder
|
||||
SharedAppToolAction.SYNC -> Icons.Default.Sync
|
||||
SharedAppToolAction.APP_THEME -> Icons.Default.Palette
|
||||
SharedAppToolAction.PRO -> Icons.Default.Star
|
||||
SharedAppToolAction.AI_SETTINGS -> Icons.Default.Settings
|
||||
SharedAppToolAction.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedAppToolAction.HELP_FEEDBACK -> Icons.Default.Feedback
|
||||
SharedAppToolAction.SUPPORT -> Icons.Default.Favorite
|
||||
SharedAppToolAction.ABOUT -> Icons.Default.Info
|
||||
SharedAppToolAction.TABS_TOGGLE -> Icons.AutoMirrored.Filled.MenuBook
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedAppTab.localizedLabel(): String {
|
||||
return when (this) {
|
||||
SharedAppTab.HOME -> readerString("nav_home", "Home")
|
||||
SharedAppTab.LIBRARY -> readerString("library_title", "Library")
|
||||
SharedAppTab.SHELVES -> readerString("tab_shelves", "Shelves")
|
||||
SharedAppTab.CATALOGS -> readerString("opds_stream", "OPDS")
|
||||
SharedAppTab.READER -> readerString("desktop_reader", "Reader")
|
||||
SharedAppTab.SETTINGS -> readerString("settings", "Settings")
|
||||
SharedAppTab.PRO -> readerString("desktop_pro", "Pro")
|
||||
SharedAppTab.PRO -> readerString("desktop_account_and_credits", "Account & credits")
|
||||
SharedAppTab.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
|
||||
SharedAppTab.SUPPORT -> readerString("desktop_support", "Support")
|
||||
SharedAppTab.FEEDBACK -> readerString("desktop_feedback", "Feedback")
|
||||
|
|
@ -566,7 +836,6 @@ private fun SharedAppTab.localizedLabel(): String {
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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
|
||||
|
|
@ -49,6 +48,7 @@ import androidx.compose.material3.Typography
|
|||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
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
|
||||
|
|
@ -64,6 +64,9 @@ 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.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
|
|
@ -267,112 +270,29 @@ fun SharedAppThemeSettingsDialog(
|
|||
onCustomThemeDeleted: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(readerString("app_theme_title", "App theme"), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column(
|
||||
SharedAppThemeControls(
|
||||
appThemeMode = appThemeMode,
|
||||
appContrastOption = appContrastOption,
|
||||
appTextDimFactorLight = appTextDimFactorLight,
|
||||
appTextDimFactorDark = appTextDimFactorDark,
|
||||
appSeedColor = appSeedColor,
|
||||
customAppThemes = customAppThemes,
|
||||
onThemeModeChanged = onThemeModeChanged,
|
||||
onContrastOptionChanged = onContrastOptionChanged,
|
||||
onTextDimFactorLightChanged = onTextDimFactorLightChanged,
|
||||
onTextDimFactorDarkChanged = onTextDimFactorDarkChanged,
|
||||
onSeedColorChanged = onSeedColorChanged,
|
||||
onCustomThemeAdded = onCustomThemeAdded,
|
||||
onCustomThemeDeleted = onCustomThemeDeleted,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 620.dp)
|
||||
.heightIn(max = 620.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SettingsLabel(readerString("app_theme_appearance", "Appearance"))
|
||||
SegmentedControl(
|
||||
values = AppThemeMode.entries,
|
||||
selectedValue = appThemeMode,
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onThemeModeChanged
|
||||
)
|
||||
|
||||
SettingsLabel(readerString("app_theme_contrast", "Contrast"))
|
||||
SegmentedControl(
|
||||
values = AppContrastOption.entries,
|
||||
selectedValue = appContrastOption,
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onContrastOptionChanged
|
||||
)
|
||||
|
||||
if (appThemeMode == AppThemeMode.SYSTEM) {
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness_light", "Text brightness (Light)"),
|
||||
value = appTextDimFactorLight,
|
||||
onValueChange = onTextDimFactorLightChanged
|
||||
)
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness_dark", "Text brightness (Dark)"),
|
||||
value = appTextDimFactorDark,
|
||||
onValueChange = onTextDimFactorDarkChanged
|
||||
)
|
||||
} else {
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness", "Text brightness"),
|
||||
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
|
||||
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
|
||||
)
|
||||
}
|
||||
|
||||
SettingsLabel(readerString("app_theme_color_scheme", "Color scheme"))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ThemeSwatch(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
selected = appSeedColor == null,
|
||||
label = readerString("app_theme_dynamic", "Dynamic"),
|
||||
onClick = { onSeedColorChanged(null) }
|
||||
)
|
||||
AppThemePresets.forEach { preset ->
|
||||
ThemeSwatch(
|
||||
color = preset.color,
|
||||
selected = appSeedColor == preset.color,
|
||||
label = readerString(preset.nameKey, preset.nameFallback),
|
||||
onClick = { onSeedColorChanged(preset.color) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SettingsLabel(readerString("theme_my_themes", "My themes"))
|
||||
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = readerString("content_desc_add_custom_theme", "Add custom theme"))
|
||||
}
|
||||
}
|
||||
|
||||
if (customAppThemes.isEmpty()) {
|
||||
Text(
|
||||
readerString("desktop_no_custom_themes_yet", "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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
|
|
@ -380,6 +300,124 @@ fun SharedAppThemeSettingsDialog(
|
|||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAppThemeControls(
|
||||
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,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SettingsLabel(readerString("app_theme_appearance", "Appearance"))
|
||||
SegmentedControl(
|
||||
values = AppThemeMode.entries,
|
||||
selectedValue = appThemeMode,
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onThemeModeChanged
|
||||
)
|
||||
|
||||
SettingsLabel(readerString("app_theme_contrast", "Contrast"))
|
||||
SegmentedControl(
|
||||
values = AppContrastOption.entries,
|
||||
selectedValue = appContrastOption,
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onContrastOptionChanged
|
||||
)
|
||||
|
||||
if (appThemeMode == AppThemeMode.SYSTEM) {
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness_light", "Text brightness (Light)"),
|
||||
value = appTextDimFactorLight,
|
||||
onValueChange = onTextDimFactorLightChanged
|
||||
)
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness_dark", "Text brightness (Dark)"),
|
||||
value = appTextDimFactorDark,
|
||||
onValueChange = onTextDimFactorDarkChanged
|
||||
)
|
||||
} else {
|
||||
TextBrightnessSlider(
|
||||
label = readerString("app_theme_text_brightness", "Text brightness"),
|
||||
value = if (appThemeMode == AppThemeMode.DARK) appTextDimFactorDark else appTextDimFactorLight,
|
||||
onValueChange = if (appThemeMode == AppThemeMode.DARK) onTextDimFactorDarkChanged else onTextDimFactorLightChanged
|
||||
)
|
||||
}
|
||||
|
||||
SettingsLabel(readerString("app_theme_color_scheme", "Color scheme"))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ThemeSwatch(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
selected = appSeedColor == null,
|
||||
label = readerString("app_theme_dynamic", "Dynamic"),
|
||||
onClick = { onSeedColorChanged(null) }
|
||||
)
|
||||
AppThemePresets.forEach { preset ->
|
||||
ThemeSwatch(
|
||||
color = preset.color,
|
||||
selected = appSeedColor == preset.color,
|
||||
label = readerString(preset.nameKey, preset.nameFallback),
|
||||
onClick = { onSeedColorChanged(preset.color) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SettingsLabel(readerString("theme_my_themes", "My themes"))
|
||||
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = readerString("content_desc_add_custom_theme", "Add custom theme"))
|
||||
}
|
||||
}
|
||||
|
||||
if (customAppThemes.isEmpty()) {
|
||||
Text(
|
||||
readerString("desktop_no_custom_themes_yet", "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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
SharedCreateAppThemeDialog(
|
||||
|
|
@ -656,11 +694,19 @@ fun SharedHsvColorPickerDialog(
|
|||
onDismiss: () -> Unit,
|
||||
onSave: (Color) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
resetColor: Color? = null,
|
||||
stateKey: Any? = null,
|
||||
onLiveColorChange: (Color) -> Unit = {},
|
||||
preview: @Composable (Color) -> Unit = {}
|
||||
) {
|
||||
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val effectiveStateKey = stateKey ?: initialColor
|
||||
var hsv by remember(effectiveStateKey) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val color = hsv.toComposeColor()
|
||||
|
||||
LaunchedEffect(effectiveStateKey, color) {
|
||||
onLiveColorChange(color)
|
||||
}
|
||||
|
||||
fun updateFromColor(nextColor: Color) {
|
||||
hsv = nextColor.toSharedHsvColor()
|
||||
}
|
||||
|
|
@ -709,7 +755,8 @@ fun SharedHsvColorPickerDialog(
|
|||
onHueSatChanged = { hue, saturation ->
|
||||
hsv = hsv.copy(hue = hue, saturation = saturation)
|
||||
},
|
||||
modifier = Modifier.size(240.dp)
|
||||
modifier = Modifier.size(240.dp),
|
||||
gestureKey = effectiveStateKey
|
||||
)
|
||||
|
||||
SharedBrightnessSlider(
|
||||
|
|
@ -717,7 +764,8 @@ fun SharedHsvColorPickerDialog(
|
|||
saturation = hsv.saturation,
|
||||
value = hsv.value,
|
||||
onValueChanged = { hsv = hsv.copy(value = it) },
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
|
||||
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp)),
|
||||
gestureKey = effectiveStateKey
|
||||
)
|
||||
|
||||
Row(
|
||||
|
|
@ -767,9 +815,14 @@ fun SharedHsvColorPickerDialog(
|
|||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (resetColor != null) {
|
||||
TextButton(onClick = { updateFromColor(resetColor) }) {
|
||||
Text(readerString("action_reset", "Reset"), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
|
|
@ -795,32 +848,23 @@ fun SharedHsvWheel(
|
|||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
gestureKey: Any? = Unit
|
||||
) {
|
||||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
val paddingPx = touchPadding.toPx()
|
||||
|
||||
fun update(offset: Offset) {
|
||||
val selection = sharedHsvWheelSelection(
|
||||
offsetX = offset.x,
|
||||
offsetY = offset.y,
|
||||
width = size.width.toFloat(),
|
||||
height = size.height.toFloat(),
|
||||
paddingPx = paddingPx
|
||||
)
|
||||
onHueSatChanged(selection.hue, selection.saturation)
|
||||
}
|
||||
|
||||
update(down.position)
|
||||
drag(down.id) { change ->
|
||||
change.consume()
|
||||
update(change.position)
|
||||
}
|
||||
modifier = modifier.pointerInput(gestureKey) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
awaitSharedColorPickerDrag { offset ->
|
||||
val selection = sharedHsvWheelSelection(
|
||||
offsetX = offset.x,
|
||||
offsetY = offset.y,
|
||||
width = size.width.toFloat(),
|
||||
height = size.height.toFloat(),
|
||||
paddingPx = paddingPx
|
||||
)
|
||||
onHueSatChanged(selection.hue, selection.saturation)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -896,7 +940,8 @@ fun SharedSpectrumBox(
|
|||
saturation: Float,
|
||||
currentColor: Color,
|
||||
onHueSatChanged: (Float, Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
gestureKey: Any? = Unit
|
||||
) {
|
||||
val rainbowColors = listOf(
|
||||
Color.Red,
|
||||
|
|
@ -910,26 +955,16 @@ fun SharedSpectrumBox(
|
|||
val touchPadding = 12.dp
|
||||
|
||||
Box(
|
||||
modifier = modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
val paddingPx = touchPadding.toPx()
|
||||
modifier = modifier.pointerInput(gestureKey) {
|
||||
val paddingPx = touchPadding.toPx()
|
||||
awaitSharedColorPickerDrag { offset ->
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -982,27 +1017,18 @@ fun SharedBrightnessSlider(
|
|||
saturation: Float,
|
||||
value: Float,
|
||||
onValueChanged: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
gestureKey: Any? = Unit
|
||||
) {
|
||||
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)
|
||||
}
|
||||
modifier = modifier.pointerInput(gestureKey) {
|
||||
awaitSharedColorPickerDrag { offset ->
|
||||
val nextValue = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onValueChanged(nextValue)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -1021,6 +1047,27 @@ fun SharedBrightnessSlider(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun PointerInputScope.awaitSharedColorPickerDrag(
|
||||
onPosition: (Offset) -> Unit
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
down.consume()
|
||||
onPosition(down.position)
|
||||
val pointerId = down.id
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == pointerId } ?: return@awaitEachGesture
|
||||
onPosition(change.position)
|
||||
change.consume()
|
||||
if (change.changedToUp() || !change.pressed) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedRgbInputColumn(
|
||||
label: String,
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ import androidx.compose.material.icons.filled.ExpandMore
|
|||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -62,6 +64,7 @@ import com.aryan.reader.shared.BookItem
|
|||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.Tag
|
||||
import com.aryan.reader.shared.cardAuthor
|
||||
import com.aryan.reader.shared.cardTitle
|
||||
import com.aryan.reader.shared.formatFileSize
|
||||
import com.aryan.reader.shared.parseTagList
|
||||
|
|
@ -131,32 +134,51 @@ fun SharedAddToShelfDialog(
|
|||
shelves: List<Shelf>,
|
||||
onDismiss: () -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
onShelfSelected: (Shelf) -> Unit
|
||||
onShelvesSelected: (Set<String>) -> Unit
|
||||
) {
|
||||
var selectedShelfIds by remember(shelves) { mutableStateOf(emptySet<String>()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(readerString("desktop_add_to_shelf", "Add to shelf")) },
|
||||
text = {
|
||||
if (shelves.isEmpty()) {
|
||||
Text(readerString("desktop_create_shelf_first", "Create a shelf first, then add selected books to it."))
|
||||
Text(readerString("desktop_create_shelf_for_books", "Create a shelf and the chosen books will be added 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)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
readerString("desktop_select_shelves_to_add", "Choose one or more shelves."),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.heightIn(max = 360.dp)) {
|
||||
items(shelves, key = { it.id }) { shelf ->
|
||||
val selected = shelf.id in selectedShelfIds
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
selectedShelfIds = selectedShelfIds.toggle(shelf.id)
|
||||
}
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = {
|
||||
selectedShelfIds = selectedShelfIds.toggle(shelf.id)
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,8 +186,22 @@ fun SharedAddToShelfDialog(
|
|||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onCreateShelf) {
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
if (shelves.isEmpty()) {
|
||||
Button(onClick = onCreateShelf) {
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onCreateShelf) {
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
Button(
|
||||
onClick = { onShelvesSelected(selectedShelfIds) },
|
||||
enabled = selectedShelfIds.isNotEmpty()
|
||||
) {
|
||||
Text(readerString("action_add", "Add"))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
|
|
@ -176,6 +212,112 @@ fun SharedAddToShelfDialog(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedManageShelfBooksDialog(
|
||||
shelf: Shelf,
|
||||
books: List<BookItem>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Set<String>) -> Unit
|
||||
) {
|
||||
var query by remember(shelf.id) { mutableStateOf("") }
|
||||
var selectedBookIds by remember(shelf.id, shelf.books) {
|
||||
mutableStateOf(shelf.books.mapTo(linkedSetOf<String>()) { it.id }.toSet())
|
||||
}
|
||||
val normalizedQuery = query.trim()
|
||||
val visibleBooks = remember(books, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
books
|
||||
} else {
|
||||
books.filter { book ->
|
||||
book.cardTitle().contains(normalizedQuery, ignoreCase = true) ||
|
||||
book.cardAuthor().contains(normalizedQuery, ignoreCase = true) ||
|
||||
book.displayName.contains(normalizedQuery, ignoreCase = true) ||
|
||||
book.tags.any { tag -> tag.name.contains(normalizedQuery, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(readerString("desktop_manage_shelf_books_title", "Manage %1\$s", shelf.name)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text(readerString("library_search_placeholder", "Search books, authors, or tags")) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
readerString("desktop_shelf_selected_book_count", "%1\$d selected", selectedBookIds.size),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (books.isEmpty()) {
|
||||
Text(readerString("your_library_empty", "Your library is empty"))
|
||||
} else if (visibleBooks.isEmpty()) {
|
||||
Text(readerString("desktop_no_books_match", "No books match."))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
items(visibleBooks, key = { it.id }) { book ->
|
||||
val selected = book.id in selectedBookIds
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
selectedBookIds = selectedBookIds.toggle(book.id)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = {
|
||||
selectedBookIds = selectedBookIds.toggle(book.id)
|
||||
}
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(book.cardTitle(), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
book.cardAuthor(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { onSave(selectedBookIds) }) {
|
||||
Text(readerString("action_save", "Save"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun Set<String>.toggle(value: String): Set<String> {
|
||||
return if (value in this) this - value else this + value
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedBookInfoDialog(
|
||||
book: BookItem,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -66,6 +66,7 @@ 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.SharedOpdsLocalBookMatcher
|
||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadState
|
||||
import com.aryan.reader.shared.opds.SharedOpdsScreenState
|
||||
import com.aryan.reader.shared.opds.SharedOpdsText
|
||||
|
|
@ -866,9 +867,7 @@ private fun SharedOpdsCatalogDialog(
|
|||
}
|
||||
|
||||
private fun OpdsEntry.findLocalBook(localLibraryBooks: List<BookItem>): BookItem? {
|
||||
return localLibraryBooks.firstOrNull {
|
||||
it.title.equals(title, ignoreCase = true) || it.displayName.equals(title, ignoreCase = true)
|
||||
}
|
||||
return SharedOpdsLocalBookMatcher.findBook(this, localLibraryBooks)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -39,6 +39,7 @@ 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.sharedPdfRichTextSelectionBounds
|
||||
import com.aryan.reader.shared.pdf.withoutTrailingSharedPdfPageBreak
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
|
@ -202,10 +203,12 @@ fun SharedPdfRichTextLayer(
|
|||
|
||||
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) {
|
||||
sharedPdfRichTextSelectionBounds(
|
||||
selectionStart = selection.start,
|
||||
selectionEnd = selection.end,
|
||||
textLength = textToRender.length
|
||||
)?.let { (localStart, localEnd) ->
|
||||
val selectionPath = measureResult.getPathForRange(localStart, localEnd)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawPath(selectionPath, Color(0xFFB3D7FF).copy(alpha = 0.5f))
|
||||
|
|
@ -213,6 +216,7 @@ fun SharedPdfRichTextLayer(
|
|||
}
|
||||
|
||||
if (selection.collapsed && controller.isCursorVisible) {
|
||||
val localStart = selection.start.coerceIn(0, textToRender.length)
|
||||
val alpha = if (isScrolling) {
|
||||
1f
|
||||
} else {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ internal data class SharedReaderModalAnchorBounds(
|
|||
)
|
||||
|
||||
internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf<SharedReaderModalAnchorBounds?> { null }
|
||||
internal val LocalSharedReaderModalFocusableOverride = compositionLocalOf<Boolean?> { null }
|
||||
|
||||
internal enum class SharedReaderModalLevel {
|
||||
Panel,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
|
|
@ -157,6 +158,11 @@ fun SharedReaderVerticalScrollbar(
|
|||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedReaderScrollbarHeight"
|
||||
)
|
||||
val currentScrollbarTrackHeight by rememberUpdatedState(scrollbarTrackHeight)
|
||||
val currentScrollbarContentHeight by rememberUpdatedState(state.contentHeightPx)
|
||||
val currentScrollbarViewportHeight by rememberUpdatedState(state.viewportHeightPx)
|
||||
val currentScrollbarProgress by rememberUpdatedState(state.progress)
|
||||
val currentScrollbarActiveThumbHeight by rememberUpdatedState(with(density) { scrollbarActiveHeight.toPx() })
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
|
@ -166,6 +172,68 @@ fun SharedReaderVerticalScrollbar(
|
|||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
.pointerInput(listState) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
val initialContentHeight = currentScrollbarContentHeight
|
||||
val initialViewportHeight = currentScrollbarViewportHeight
|
||||
val initialMaxScroll = (initialContentHeight - initialViewportHeight).coerceAtLeast(0f)
|
||||
val initialTrackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: initialViewportHeight
|
||||
val initialThumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val initialTrackSpace = (initialTrackHeight - initialThumbHeight).coerceAtLeast(1f)
|
||||
val initialThumbTop = (initialTrackSpace * currentScrollbarProgress)
|
||||
.coerceIn(0f, initialTrackSpace)
|
||||
val downY = down.position.y.coerceIn(0f, initialTrackHeight)
|
||||
val thumbDragOffset = if (downY in initialThumbTop..(initialThumbTop + initialThumbHeight)) {
|
||||
downY - initialThumbTop
|
||||
} else {
|
||||
initialThumbHeight / 2f
|
||||
}
|
||||
var lastScrollPx = currentScrollbarProgress * initialMaxScroll
|
||||
|
||||
fun scrollToPointer(pointerY: Float) {
|
||||
val contentHeight = currentScrollbarContentHeight
|
||||
val viewportHeight = currentScrollbarViewportHeight
|
||||
val maxScroll = (contentHeight - viewportHeight).coerceAtLeast(0f)
|
||||
if (maxScroll <= 0f) return
|
||||
val trackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: viewportHeight
|
||||
val thumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val trackSpace = (trackHeight - thumbHeight).coerceAtLeast(1f)
|
||||
val targetThumbTop = (pointerY - thumbDragOffset).coerceIn(0f, trackSpace)
|
||||
val targetScrollPx = (targetThumbTop / trackSpace) * maxScroll
|
||||
val scrollDelta = targetScrollPx - lastScrollPx
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
lastScrollPx = targetScrollPx
|
||||
}
|
||||
|
||||
scrollToPointer(down.position.y)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
if (change.position.y != change.previousPosition.y) {
|
||||
change.consume()
|
||||
scrollToPointer(change.position.y)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
|
|
@ -184,39 +252,6 @@ fun SharedReaderVerticalScrollbar(
|
|||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(36.dp)
|
||||
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
val deltaY = change.position.y - change.previousPosition.y
|
||||
if (deltaY != 0f) {
|
||||
change.consume()
|
||||
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: state.viewportHeightPx
|
||||
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
|
||||
.coerceAtLeast(1f)
|
||||
val scrollDelta = (deltaY / trackSpace) *
|
||||
(state.contentHeightPx - state.viewportHeightPx)
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -314,6 +349,11 @@ fun SharedPdfVerticalScrollbar(
|
|||
targetValue = if (isDraggingScrollbar) scrollbarActiveHeight else scrollbarIdleHeight,
|
||||
label = "sharedPdfScrollbarHeight"
|
||||
)
|
||||
val currentScrollbarTrackHeight by rememberUpdatedState(scrollbarTrackHeight)
|
||||
val currentScrollbarContentHeight by rememberUpdatedState(state.contentHeightPx)
|
||||
val currentScrollbarViewportHeight by rememberUpdatedState(state.viewportHeightPx)
|
||||
val currentScrollbarProgress by rememberUpdatedState(state.progress)
|
||||
val currentScrollbarActiveThumbHeight by rememberUpdatedState(with(density) { scrollbarActiveHeight.toPx() })
|
||||
val safeCurrentPage = if (pageCount > 0) currentPage.coerceIn(0, pageCount - 1) else 0
|
||||
|
||||
Box(
|
||||
|
|
@ -324,6 +364,68 @@ fun SharedPdfVerticalScrollbar(
|
|||
.onGloballyPositioned { coordinates ->
|
||||
scrollbarTrackHeight = coordinates.size.height.toFloat()
|
||||
}
|
||||
.pointerInput(listState) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
val initialContentHeight = currentScrollbarContentHeight
|
||||
val initialViewportHeight = currentScrollbarViewportHeight
|
||||
val initialMaxScroll = (initialContentHeight - initialViewportHeight).coerceAtLeast(0f)
|
||||
val initialTrackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: initialViewportHeight
|
||||
val initialThumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val initialTrackSpace = (initialTrackHeight - initialThumbHeight).coerceAtLeast(1f)
|
||||
val initialThumbTop = (initialTrackSpace * currentScrollbarProgress)
|
||||
.coerceIn(0f, initialTrackSpace)
|
||||
val downY = down.position.y.coerceIn(0f, initialTrackHeight)
|
||||
val thumbDragOffset = if (downY in initialThumbTop..(initialThumbTop + initialThumbHeight)) {
|
||||
downY - initialThumbTop
|
||||
} else {
|
||||
initialThumbHeight / 2f
|
||||
}
|
||||
var lastScrollPx = currentScrollbarProgress * initialMaxScroll
|
||||
|
||||
fun scrollToPointer(pointerY: Float) {
|
||||
val contentHeight = currentScrollbarContentHeight
|
||||
val viewportHeight = currentScrollbarViewportHeight
|
||||
val maxScroll = (contentHeight - viewportHeight).coerceAtLeast(0f)
|
||||
if (maxScroll <= 0f) return
|
||||
val trackHeight = currentScrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: viewportHeight
|
||||
val thumbHeight = currentScrollbarActiveThumbHeight.coerceAtLeast(1f)
|
||||
val trackSpace = (trackHeight - thumbHeight).coerceAtLeast(1f)
|
||||
val targetThumbTop = (pointerY - thumbDragOffset).coerceIn(0f, trackSpace)
|
||||
val targetScrollPx = (targetThumbTop / trackSpace) * maxScroll
|
||||
val scrollDelta = targetScrollPx - lastScrollPx
|
||||
if (abs(scrollDelta) > 0.01f) {
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
lastScrollPx = targetScrollPx
|
||||
}
|
||||
|
||||
scrollToPointer(down.position.y)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
if (change.position.y != change.previousPosition.y) {
|
||||
change.consume()
|
||||
scrollToPointer(change.position.y)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val thumbHeightPx = with(density) { barHeight.toPx() }
|
||||
val effectiveTrackHeight = scrollbarTrackHeight.takeIf { it > 0f } ?: state.viewportHeightPx
|
||||
|
|
@ -367,39 +469,6 @@ fun SharedPdfVerticalScrollbar(
|
|||
modifier = Modifier
|
||||
.height(barHeight)
|
||||
.width(48.dp)
|
||||
.pointerInput(scrollbarTrackHeight, state.contentHeightPx, state.viewportHeightPx) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
try {
|
||||
isDraggingScrollbar = true
|
||||
scrollbarVisible = true
|
||||
scrollInteractionTick += 1
|
||||
down.consume()
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
if (change == null || !change.pressed) break
|
||||
|
||||
val deltaY = change.position.y - change.previousPosition.y
|
||||
if (deltaY != 0f) {
|
||||
change.consume()
|
||||
val trackHeight = scrollbarTrackHeight.takeIf { it > 0f }
|
||||
?: state.viewportHeightPx
|
||||
val trackSpace = (trackHeight - with(density) { scrollbarActiveHeight.toPx() })
|
||||
.coerceAtLeast(1f)
|
||||
val scrollDelta = (deltaY / trackSpace) *
|
||||
(state.contentHeightPx - state.viewportHeightPx)
|
||||
listState.dispatchRawDelta(scrollDelta)
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isDraggingScrollbar = false
|
||||
scrollInteractionTick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.ReaderCloudTtsState
|
||||
import com.aryan.reader.shared.ReaderTtsReadScope
|
||||
import com.aryan.reader.shared.readerCloudTtsControlsModel
|
||||
import com.aryan.reader.shared.readerCloudTtsVoiceById
|
||||
|
||||
@Composable
|
||||
fun SharedReaderTtsOverlayControls(
|
||||
settings: ReaderAiByokSettings,
|
||||
cloudTts: ReaderCloudTtsState,
|
||||
credits: Int,
|
||||
showCredits: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
onCollapseChange: (Boolean) -> Unit,
|
||||
onPauseResume: () -> Unit,
|
||||
onSkipPrevious: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
onLocateCurrentChunk: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val sanitized = settings.sanitized()
|
||||
val controls = readerCloudTtsControlsModel(cloudTts)
|
||||
if (!controls.isVisible) return
|
||||
|
||||
val voice = remember(sanitized.ttsSpeakerId) { readerCloudTtsVoiceById(sanitized.ttsSpeakerId) }
|
||||
val progress = cloudTts.progress
|
||||
val chunk = progress.currentChunk
|
||||
val chunkLabel = if (progress.currentChunkIndex >= 0 && progress.chunks.isNotEmpty()) {
|
||||
readerString(
|
||||
"desktop_tts_chunk_count",
|
||||
"Part %1\$d/%2\$d",
|
||||
progress.currentChunkIndex + 1,
|
||||
progress.chunks.size
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val progressFraction = if (progress.currentChunkIndex >= 0 && progress.chunks.isNotEmpty()) {
|
||||
((progress.currentChunkIndex + 1).toFloat() / progress.chunks.size).coerceIn(0f, 1f)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val title = when {
|
||||
cloudTts.isLoading -> readerString("desktop_preparing_audio", "Preparing audio")
|
||||
cloudTts.isPaused -> readerString("desktop_paused", "Paused")
|
||||
cloudTts.isPlaying -> readerString("label_reading", "Reading")
|
||||
else -> readerString("action_read_aloud", "Read aloud")
|
||||
}
|
||||
val chapterLabel = chunk?.chapterTitle
|
||||
?.lineSequence()
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotBlank() }
|
||||
?.joinToString(" - ")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val statusLine = cloudTts.errorMessage
|
||||
?: progress.currentPositionLabel
|
||||
?: cloudTts.statusMessage
|
||||
?: chapterLabel
|
||||
?: voice?.let { "${it.name}: ${it.description}" }
|
||||
?: ""
|
||||
val scopeLabel = readerTtsScopeLabel(progress.scope)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
|
||||
modifier = modifier.widthIn(max = 560.dp).animateContentSize()
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isCollapsed,
|
||||
transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
|
||||
label = "SharedReaderTtsOverlay"
|
||||
) { collapsed ->
|
||||
if (collapsed) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onCollapseChange(false) },
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowLeft,
|
||||
contentDescription = readerString("content_desc_expand", "Expand"),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
chunkLabel ?: scopeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
SharedReaderTtsPlayPauseButton(
|
||||
isPlaying = cloudTts.isPlaying,
|
||||
isLoading = cloudTts.isLoading,
|
||||
enabled = controls.canPauseResume,
|
||||
size = 36,
|
||||
iconSize = 20,
|
||||
onClick = onPauseResume
|
||||
)
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = readerString("content_desc_stop_tts", "Stop read aloud"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (statusLine.isNotBlank()) {
|
||||
Text(
|
||||
statusLine,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (cloudTts.errorMessage != null) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
IconButton(
|
||||
enabled = controls.canLocateCurrentChunk,
|
||||
onClick = onLocateCurrentChunk,
|
||||
modifier = Modifier.size(34.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MyLocation,
|
||||
contentDescription = readerString("desktop_locate_current_tts", "Locate current reading"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(34.dp)) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowRight,
|
||||
contentDescription = readerString("content_desc_collapse", "Collapse"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(34.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = readerString("content_desc_stop_tts", "Stop read aloud"),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SharedReaderTtsPill(readerString("tts_mode_cloud_ai", "Cloud AI"))
|
||||
voice?.let { SharedReaderTtsPill(it.name) }
|
||||
if (showCredits) {
|
||||
SharedReaderTtsPill(readerString("credits_count", "%1\$d credits", credits))
|
||||
}
|
||||
SharedReaderTtsPill(chunkLabel ?: scopeLabel)
|
||||
}
|
||||
|
||||
progressFraction?.let { fraction ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
IconButton(
|
||||
enabled = controls.canSkipPrevious,
|
||||
onClick = onSkipPrevious,
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipPrevious,
|
||||
contentDescription = readerString("content_desc_tts_previous_chunk", "Previous chunk"),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
SharedReaderTtsPlayPauseButton(
|
||||
isPlaying = cloudTts.isPlaying,
|
||||
isLoading = cloudTts.isLoading,
|
||||
enabled = controls.canPauseResume,
|
||||
size = 56,
|
||||
iconSize = 28,
|
||||
onClick = onPauseResume
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
IconButton(
|
||||
enabled = controls.canSkipNext,
|
||||
onClick = onSkipNext,
|
||||
modifier = Modifier.size(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SkipNext,
|
||||
contentDescription = readerString("content_desc_tts_next_chunk", "Next chunk"),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedReaderTtsPill(text: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedReaderTtsPlayPauseButton(
|
||||
isPlaying: Boolean,
|
||||
isLoading: Boolean,
|
||||
enabled: Boolean,
|
||||
size: Int,
|
||||
iconSize: Int,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.size(size.dp), contentAlignment = Alignment.Center) {
|
||||
FilledIconButton(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(size.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = readerString("content_desc_play_pause", "Play or pause"),
|
||||
modifier = Modifier.size(iconSize.dp)
|
||||
)
|
||||
}
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(size.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
strokeWidth = if (size >= 56) 3.dp else 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun readerTtsScopeLabel(scope: ReaderTtsReadScope): String {
|
||||
return when (scope) {
|
||||
ReaderTtsReadScope.PAGE -> readerString("desktop_page", "Page")
|
||||
ReaderTtsReadScope.CHAPTER -> readerString("chapter", "Chapter")
|
||||
ReaderTtsReadScope.BOOK -> readerString("desktop_from_here", "From here")
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
|
|||
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.ReaderAction
|
||||
import com.aryan.reader.shared.ReaderTheme
|
||||
import com.aryan.reader.shared.ReaderToolbarPreferences
|
||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||
import com.aryan.reader.shared.SharedSettingsAction
|
||||
|
|
@ -78,6 +79,8 @@ fun SharedSettingsHub(
|
|||
onReaderToolbarPreferencesChange: (ReaderToolbarPreferences) -> Unit = {},
|
||||
customFonts: List<CustomFontItem> = emptyList(),
|
||||
onPickCustomFont: (() -> String?)? = null,
|
||||
customReaderThemes: List<ReaderTheme> = emptyList(),
|
||||
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit = {},
|
||||
readerCustomTextureIds: List<String> = emptyList(),
|
||||
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)? = null,
|
||||
showTopBar: Boolean = true,
|
||||
|
|
@ -167,6 +170,8 @@ fun SharedSettingsHub(
|
|||
onTtsReplacementPreferencesChange = onTtsReplacementPreferencesChange,
|
||||
customFonts = customFonts,
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
customReaderThemes = customReaderThemes,
|
||||
onCustomReaderThemesChange = onCustomReaderThemesChange,
|
||||
readerCustomTextureIds = readerCustomTextureIds,
|
||||
onImportReaderTexture = onImportReaderTexture,
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -490,6 +495,8 @@ private fun SharedSettingsDetailPage(
|
|||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit,
|
||||
customFonts: List<CustomFontItem>,
|
||||
onPickCustomFont: (() -> String?)?,
|
||||
customReaderThemes: List<ReaderTheme>,
|
||||
onCustomReaderThemesChange: (List<ReaderTheme>) -> Unit,
|
||||
readerCustomTextureIds: List<String>,
|
||||
onImportReaderTexture: ((ReaderSettings) -> ReaderSettings?)?,
|
||||
modifier: Modifier
|
||||
|
|
@ -510,7 +517,6 @@ private fun SharedSettingsDetailPage(
|
|||
SharedSettingsDestination.EPUB_FORMAT -> {
|
||||
SharedReaderFormatControls(
|
||||
settings = settings,
|
||||
toolbarPreferences = ReaderToolbarPreferences(),
|
||||
onPickCustomFont = onPickCustomFont,
|
||||
customFonts = customFonts,
|
||||
onReaderAction = { action ->
|
||||
|
|
@ -521,6 +527,8 @@ private fun SharedSettingsDetailPage(
|
|||
SharedSettingsDestination.EPUB_THEME_TEXTURE -> {
|
||||
SharedReaderThemeControls(
|
||||
settings = settings,
|
||||
customThemes = customReaderThemes,
|
||||
onCustomThemesChange = onCustomReaderThemesChange,
|
||||
customTextureIds = readerCustomTextureIds,
|
||||
onImportTexture = onImportReaderTexture,
|
||||
onSettingsChange = onSettingsChange
|
||||
|
|
@ -547,15 +555,25 @@ private fun SharedSettingsDetailPage(
|
|||
SharedReaderThemeControls(
|
||||
settings = pdfSettings,
|
||||
builtInThemes = BuiltInPdfReaderThemes,
|
||||
customThemes = customReaderThemes,
|
||||
onCustomThemesChange = onCustomReaderThemesChange,
|
||||
customTextureIds = readerCustomTextureIds,
|
||||
onImportTexture = onImportReaderTexture,
|
||||
onSettingsChange = onPdfSettingsChange
|
||||
)
|
||||
HorizontalDivider()
|
||||
Text(readerString("visual_options_title", "Visual options"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = readerString("menu_right_to_left_pagination", "Paginated (right-to-left)"),
|
||||
summary = readerString("visual_options_right_to_left_pagination_desc", "Uses right-to-left page order when PDF pagination mode is active."),
|
||||
checked = pdfSettings.rightToLeftPagination,
|
||||
onCheckedChange = { enabled ->
|
||||
onPdfSettingsChange(pdfSettings.copy(rightToLeftPagination = enabled))
|
||||
}
|
||||
)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = readerString("visual_options_remove_page_gap", "Remove gap between pages"),
|
||||
summary = readerString("desktop_remove_gap_between_pages_desc", "Applies to vertical reading mode."),
|
||||
summary = readerString("desktop_remove_gap_between_pages_desc", "Applies to vertical reading and two-page spreads."),
|
||||
checked = !pdfSettings.pdfVerticalPageGapVisible,
|
||||
onCheckedChange = { removeGap ->
|
||||
onPdfSettingsChange(pdfSettings.copy(pdfVerticalPageGapVisible = !removeGap))
|
||||
|
|
|
|||
|
|
@ -32,8 +32,11 @@ 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.FileOpen
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Policy
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.AlertDialog
|
||||
|
|
@ -494,6 +497,9 @@ fun SharedAboutScreen(
|
|||
buildLabel: String,
|
||||
onOpenSource: (() -> Unit)? = null,
|
||||
onOpenIssues: (() -> Unit)? = null,
|
||||
onOpenPrivacyPolicy: (() -> Unit)? = null,
|
||||
onOpenTerms: (() -> Unit)? = null,
|
||||
onOpenLicenses: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
|
|
@ -544,6 +550,30 @@ fun SharedAboutScreen(
|
|||
onClick = onOpenIssues
|
||||
)
|
||||
}
|
||||
if (onOpenPrivacyPolicy != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = readerString("legal_privacy_policy", "Privacy Policy"),
|
||||
body = readerString("about_privacy_desc", "How Episteme handles data for this edition."),
|
||||
icon = { Icon(Icons.Default.Policy, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenPrivacyPolicy
|
||||
)
|
||||
}
|
||||
if (onOpenTerms != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = readerString("legal_terms_of_service", "Terms of Service"),
|
||||
body = readerString("about_terms_desc", "Usage terms and conditions."),
|
||||
icon = { Icon(Icons.Default.Gavel, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenTerms
|
||||
)
|
||||
}
|
||||
if (onOpenLicenses != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = readerString("legal_licenses", "Licenses"),
|
||||
body = readerString("about_licenses_desc", "Open source libraries used."),
|
||||
icon = { Icon(Icons.Default.FileOpen, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenLicenses
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue