Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE * Refactored desktop reader screens and state management into specialized components * Added image gallery to reader sidebar and refactored desktop PDF UI components * Implemented EPUB image gallery * Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic * Standardized UI styling and refactored app navigation layout * Implemented auto-hiding reader chrome and activity tracking in desktop * Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support. * Added PPTX support for desktop and refactored parsing into a shared module * Implement paid AI features and account management for the desktop application. * Implement AI Hub and enhanced Cloud TTS integration for Desktop * Implement streaming support for AI definition and summarization features * Implement support for password-protected PDFs and file actions in the desktop reader. * Implement cloud synchronization for desktop using Firestore and Google Drive * Implement PDF reflow and "Text View" for the desktop reader * Refactor OPDS logic to use SharedOpdsController * Optimize PDF tile rendering performance * Implement two-page spread support for PDF pagination * Implement two-page spread support for the PDF viewer * Improved shared spread zoom in PDF viewer * Improve PDF spread navigation with fling support and configurable page gaps * Add brightness control to PDF and EPUB readers * Refactor folder synchronization to use shared logic engine * Implement safe string formatting and validation for localized resources * Implement TTS chunk skip navigation * Implement deep-linking and playback controls for TTS media sessions * Implement start index for TTS playback * Improve TTS navigation, prefetching, and notification duration reporting * Implement TTS mini playback bar for background reading * Implement multi-window reader support for the desktop application * Improve desktop modal window management and visibility syncing * Implement localized string support for Desktop and shared UI * Implement language selection and persistence for Desktop * Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml * Implement localized banner messages and UI strings using resource-backed SharedText * Implement compact badge styling for small book covers * Refactor PDF native interaction and improve HTML import memory safety * fix language persistence * Refactor reader overflow menus to use section-based logic * Refactor PDF layout remapping and improve text box interaction * Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets * Centralize PDF annotation export mapping and improve metadata handling * Add support for threaded comments in PDF highlight annotations * Flatten highlight comments into a single thread for PDF export and allow author editing * Integrate page slider into reader chrome and persist toggle state * Handle fragments and queries in EPUB chapter paths * Implement dynamic, theme-aware coloring for the reader slider * Implement customizable app-wide font preference * Implement one-hand zoom gestures in the PDF viewer * Implement File Information dialog for PDF and EPUB readers * Bump version to 1.0.49 (53) * Refactor PDF reader logic into modular components * Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens * Add option to use PDF filenames as display names * Fix preservation of PDF filename display preference in library projection
This commit is contained in:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
|
|
@ -73,6 +73,7 @@ sealed interface AppAction {
|
|||
data class AppTextDimFactorLightChanged(val factor: Float) : AppAction
|
||||
data class AppTextDimFactorDarkChanged(val factor: Float) : AppAction
|
||||
data class AppSeedColorChanged(val color: Color?) : AppAction
|
||||
data class AppFontPreferenceChanged(val preference: AppFontPreference) : AppAction
|
||||
data class CustomAppThemeAdded(val theme: CustomAppTheme) : AppAction
|
||||
data class CustomAppThemeDeleted(val themeId: String) : AppAction
|
||||
data class SyncEnabledChanged(val enabled: Boolean) : AppAction
|
||||
|
|
|
|||
|
|
@ -4,11 +4,124 @@ import androidx.compose.ui.graphics.Color
|
|||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
data class SharedText(
|
||||
val name: String,
|
||||
val fallback: String,
|
||||
val args: List<Any?> = emptyList(),
|
||||
val quantity: Int? = null,
|
||||
val fallbackOther: String = fallback
|
||||
) {
|
||||
fun fallbackMessage(): String {
|
||||
val template = if (quantity == null || quantity == 1) fallback else fallbackOther
|
||||
return formatSharedTextFallback(template, args)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun string(name: String, fallback: String, vararg args: Any?): SharedText {
|
||||
return SharedText(name = name, fallback = fallback, args = args.toList())
|
||||
}
|
||||
|
||||
fun quantity(
|
||||
name: String,
|
||||
quantity: Int,
|
||||
fallbackOne: String,
|
||||
fallbackOther: String,
|
||||
vararg args: Any?
|
||||
): SharedText {
|
||||
return SharedText(
|
||||
name = name,
|
||||
fallback = fallbackOne,
|
||||
args = args.toList(),
|
||||
quantity = quantity,
|
||||
fallbackOther = fallbackOther
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class BannerMessage(
|
||||
val message: String,
|
||||
val isError: Boolean = false,
|
||||
val isPersistent: Boolean = false
|
||||
)
|
||||
val isPersistent: Boolean = false,
|
||||
val text: SharedText? = null
|
||||
) {
|
||||
companion object {
|
||||
fun localized(
|
||||
text: SharedText,
|
||||
isError: Boolean = false,
|
||||
isPersistent: Boolean = false
|
||||
): BannerMessage {
|
||||
return BannerMessage(
|
||||
message = text.fallbackMessage(),
|
||||
isError = isError,
|
||||
isPersistent = isPersistent,
|
||||
text = text
|
||||
)
|
||||
}
|
||||
|
||||
fun string(
|
||||
name: String,
|
||||
fallback: String,
|
||||
vararg args: Any?,
|
||||
isError: Boolean = false,
|
||||
isPersistent: Boolean = false
|
||||
): BannerMessage {
|
||||
return localized(
|
||||
text = SharedText.string(name, fallback, *args),
|
||||
isError = isError,
|
||||
isPersistent = isPersistent
|
||||
)
|
||||
}
|
||||
|
||||
fun quantity(
|
||||
name: String,
|
||||
quantity: Int,
|
||||
fallbackOne: String,
|
||||
fallbackOther: String,
|
||||
vararg args: Any?,
|
||||
isError: Boolean = false,
|
||||
isPersistent: Boolean = false
|
||||
): BannerMessage {
|
||||
return localized(
|
||||
text = SharedText.quantity(name, quantity, fallbackOne, fallbackOther, *args),
|
||||
isError = isError,
|
||||
isPersistent = isPersistent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatSharedTextFallback(template: String, args: List<Any?>): String {
|
||||
if (args.isEmpty()) return template.replace("%%", "%")
|
||||
|
||||
val percentPlaceholder = "\u0000PERCENT\u0000"
|
||||
var sequentialIndex = 0
|
||||
var formatted = template.replace("%%", percentPlaceholder)
|
||||
|
||||
formatted = Regex("%(\\d+)\\$[-+#, .(]*\\d*(?:\\.\\d+)?[a-zA-Z]").replace(formatted) { match ->
|
||||
val argIndex = match.groupValues[1].toIntOrNull()?.minus(1)
|
||||
args.getOrNull(argIndex ?: -1).toSharedTextArgument()
|
||||
}
|
||||
|
||||
formatted = Regex("%[-+#, .(]*\\d*(?:\\.\\d+)?[a-zA-Z]").replace(formatted) {
|
||||
args.getOrNull(sequentialIndex++).toSharedTextArgument()
|
||||
}
|
||||
|
||||
return formatted.replace(percentPlaceholder, "%")
|
||||
}
|
||||
|
||||
private fun Any?.toSharedTextArgument(): String {
|
||||
return when (this) {
|
||||
null -> ""
|
||||
is Float -> trimSharedTextTrailingZeroDecimal(toString())
|
||||
is Double -> trimSharedTextTrailingZeroDecimal(toString())
|
||||
else -> toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimSharedTextTrailingZeroDecimal(value: String): String {
|
||||
return value.removeSuffix(".0")
|
||||
}
|
||||
|
||||
data class ImportResult(
|
||||
val uriString: String,
|
||||
|
|
@ -42,6 +155,44 @@ enum class AppContrastOption(val value: Double) {
|
|||
HIGH(1.0)
|
||||
}
|
||||
|
||||
enum class AppFontPreferenceKind {
|
||||
SYSTEM,
|
||||
SERIF,
|
||||
SANS_SERIF,
|
||||
MONOSPACE,
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
data class AppFontPreference(
|
||||
val kind: AppFontPreferenceKind = AppFontPreferenceKind.SYSTEM,
|
||||
val customFontId: String? = null
|
||||
) {
|
||||
fun sanitized(): AppFontPreference {
|
||||
return when (kind) {
|
||||
AppFontPreferenceKind.CUSTOM -> customFontId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { copy(customFontId = it) }
|
||||
?: System
|
||||
else -> copy(customFontId = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun referencesCustomFont(fontId: String): Boolean {
|
||||
return kind == AppFontPreferenceKind.CUSTOM && customFontId == fontId
|
||||
}
|
||||
|
||||
companion object {
|
||||
val System = AppFontPreference(AppFontPreferenceKind.SYSTEM)
|
||||
val Serif = AppFontPreference(AppFontPreferenceKind.SERIF)
|
||||
val SansSerif = AppFontPreference(AppFontPreferenceKind.SANS_SERIF)
|
||||
val Monospace = AppFontPreference(AppFontPreferenceKind.MONOSPACE)
|
||||
|
||||
fun custom(customFontId: String): AppFontPreference {
|
||||
return AppFontPreference(AppFontPreferenceKind.CUSTOM, customFontId).sanitized()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CustomAppTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
|
@ -113,11 +264,13 @@ data class SharedReaderScreenState(
|
|||
val showExternalFileSavePromptFor: String? = null,
|
||||
val externalFileBehavior: String = "ASK",
|
||||
val useStrictFileFilter: Boolean = false,
|
||||
val usePdfFileNameAsDisplayName: Boolean = false,
|
||||
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
|
||||
val appContrastOption: AppContrastOption = AppContrastOption.STANDARD,
|
||||
val appTextDimFactorLight: Float = 1.0f,
|
||||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: Color? = null,
|
||||
val appFontPreference: AppFontPreference = AppFontPreference.System,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
|
|
|
|||
|
|
@ -68,6 +68,33 @@ object SharedFileCapabilities {
|
|||
"text/x-log"
|
||||
)
|
||||
|
||||
val androidFilePickerMimeTypes: List<String> = listOf(
|
||||
"application/pdf",
|
||||
"application/epub+zip",
|
||||
"application/x-mobipocket-ebook",
|
||||
"application/vnd.amazon.ebook",
|
||||
"application/vnd.amazon.mobi8-ebook",
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"text/plain",
|
||||
"text/html",
|
||||
"application/xhtml+xml",
|
||||
"application/x-fictionbook+xml",
|
||||
"application/x-zip-compressed-fb2",
|
||||
"application/zip",
|
||||
"application/vnd.comicbook+zip",
|
||||
"application/x-cbz",
|
||||
"application/vnd.comicbook-rar",
|
||||
"application/x-cbr",
|
||||
"application/x-rar-compressed",
|
||||
"application/x-cb7",
|
||||
"application/x-7z-compressed",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/x-vnd.oasis.opendocument.text-flat-xml"
|
||||
) + manualOnlyReaderMimeTypes
|
||||
|
||||
val all: List<FileTypeCapability> = listOf(
|
||||
FileTypeCapability(
|
||||
type = FileType.EPUB,
|
||||
|
|
@ -165,7 +192,7 @@ object SharedFileCapabilities {
|
|||
displayName = "PPTX",
|
||||
extensions = setOf("pptx"),
|
||||
androidSurface = ReaderFeatureSurface.PDF_VIEWER,
|
||||
desktopSurface = null
|
||||
desktopSurface = ReaderFeatureSurface.PDF_VIEWER
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -217,6 +244,48 @@ object SharedFileCapabilities {
|
|||
return fileTypeForEffectiveName(effectiveName)
|
||||
}
|
||||
|
||||
fun resolveFileTypeForMetadata(fileName: String?, mimeType: String?): FileType? {
|
||||
val normalizedMimeType = mimeType
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
return when (normalizedMimeType) {
|
||||
"application/vnd.oasis.opendocument.text" -> FileType.ODT
|
||||
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" -> FileType.PPTX
|
||||
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
|
||||
when {
|
||||
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
|
||||
fileName?.endsWith(".fb2.zip", ignoreCase = true) == true -> FileType.FB2
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
"application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> {
|
||||
if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null
|
||||
}
|
||||
"application/x-cb7", "application/x-7z-compressed" -> {
|
||||
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
|
||||
}
|
||||
"application/pdf" -> FileType.PDF
|
||||
"application/epub+zip" -> FileType.EPUB
|
||||
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
|
||||
"application/x-mobipocket-ebook",
|
||||
"application/vnd.amazon.ebook",
|
||||
"application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
|
||||
"text/markdown", "text/x-markdown" -> FileType.MD
|
||||
"text/html", "application/xhtml+xml" -> FileType.HTML
|
||||
"text/plain" -> resolveFileTypeForName(fileName) ?: FileType.TXT
|
||||
else -> {
|
||||
if (normalizedMimeType != null && normalizedMimeType in manualOnlyReaderMimeTypes) {
|
||||
FileType.HTML
|
||||
} else {
|
||||
resolveFileTypeForName(fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isCodeOrDataFileName(fileName: String): Boolean {
|
||||
return fileName.normalizedFileName()
|
||||
.withTransparentTextSuffix()
|
||||
|
|
@ -228,7 +297,11 @@ object SharedFileCapabilities {
|
|||
}
|
||||
|
||||
fun isManualOnlyReaderMimeType(mimeType: String?): Boolean {
|
||||
val normalized = mimeType?.lowercase() ?: return false
|
||||
val normalized = mimeType
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
?: return false
|
||||
return normalized in manualOnlyReaderMimeTypes
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +345,12 @@ object SharedFileCapabilities {
|
|||
}
|
||||
}
|
||||
|
||||
fun readableTypesFor(platform: ReaderPlatform, surface: ReaderFeatureSurface): Set<FileType> {
|
||||
return all.mapNotNullTo(mutableSetOf()) { capability ->
|
||||
capability.type.takeIf { capability.surfaceFor(platform) == surface }
|
||||
}
|
||||
}
|
||||
|
||||
fun syncableTypesFor(platform: ReaderPlatform): Set<FileType> {
|
||||
return all.mapNotNullTo(mutableSetOf()) { capability ->
|
||||
capability.type.takeIf { capability.syncEligible && capability.surfaceFor(platform) != null }
|
||||
|
|
|
|||
|
|
@ -8,19 +8,17 @@ enum class FileType {
|
|||
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT, PPTX, UNKNOWN
|
||||
}
|
||||
|
||||
val PDF_VIEWER_FILE_TYPES = setOf(FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7, FileType.PPTX)
|
||||
val PDF_VIEWER_FILE_TYPES: Set<FileType>
|
||||
get() = SharedFileCapabilities.readableTypesFor(
|
||||
ReaderPlatform.ANDROID,
|
||||
ReaderFeatureSurface.PDF_VIEWER
|
||||
)
|
||||
|
||||
val EPUB_READER_FILE_TYPES = setOf(
|
||||
FileType.EPUB,
|
||||
FileType.MOBI,
|
||||
FileType.MD,
|
||||
FileType.TXT,
|
||||
FileType.HTML,
|
||||
FileType.FB2,
|
||||
FileType.DOCX,
|
||||
FileType.ODT,
|
||||
FileType.FODT
|
||||
)
|
||||
val EPUB_READER_FILE_TYPES: Set<FileType>
|
||||
get() = SharedFileCapabilities.readableTypesFor(
|
||||
ReaderPlatform.ANDROID,
|
||||
ReaderFeatureSurface.EPUB_READER
|
||||
)
|
||||
|
||||
enum class AddBooksSource {
|
||||
UNSHELVED,
|
||||
|
|
@ -141,7 +139,8 @@ data class LibraryState(
|
|||
val filters: LibraryFilters = LibraryFilters(),
|
||||
val selectedBookIds: Set<String> = emptySet(),
|
||||
val recentLimit: Int = 12,
|
||||
val message: String? = null
|
||||
val message: String? = null,
|
||||
val messageText: SharedText? = null
|
||||
)
|
||||
|
||||
data class HomeScreenModel(
|
||||
|
|
|
|||
|
|
@ -65,7 +65,13 @@ object SharedLibraryEditor {
|
|||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.filterNot { it.id in selected },
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Removed ${selected.size} book(s) from the library.")
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
"banner_books_removed_library",
|
||||
selected.size,
|
||||
"%1\$d book removed from library.",
|
||||
"%1\$d books removed from library.",
|
||||
selected.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs.filterNot { it.bookId in selected }
|
||||
|
|
@ -81,7 +87,13 @@ object SharedLibraryEditor {
|
|||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Created shelf \"$trimmed\".")),
|
||||
state = state.copy(
|
||||
bannerMessage = BannerMessage.string(
|
||||
"banner_shelf_created",
|
||||
"Created shelf \"%1\$s\".",
|
||||
trimmed
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords + ShelfRecord(id = "shelf_$nowMillis", name = trimmed),
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
|
|
@ -102,7 +114,13 @@ object SharedLibraryEditor {
|
|||
if (cleanedRules.isEmpty()) return null
|
||||
val cleanedDefinition = definition.copy(rules = cleanedRules)
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Created smart shelf \"$trimmed\".")),
|
||||
state = state.copy(
|
||||
bannerMessage = BannerMessage.string(
|
||||
"banner_smart_shelf_created",
|
||||
"Created smart shelf \"%1\$s\".",
|
||||
trimmed
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords + ShelfRecord(
|
||||
id = "smart_$nowMillis",
|
||||
name = trimmed,
|
||||
|
|
@ -122,7 +140,13 @@ object SharedLibraryEditor {
|
|||
): SharedLibraryMutationResult? {
|
||||
val trimmed = cleanShelfName(name) ?: return null
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Renamed shelf to \"$trimmed\".")),
|
||||
state = state.copy(
|
||||
bannerMessage = BannerMessage.string(
|
||||
"banner_shelf_renamed",
|
||||
"Renamed shelf to \"%1\$s\".",
|
||||
trimmed
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords.map { if (it.id == shelf.id) it.copy(name = trimmed) else it },
|
||||
shelfRefs = shelfRefs
|
||||
)
|
||||
|
|
@ -135,7 +159,13 @@ object SharedLibraryEditor {
|
|||
shelf: Shelf
|
||||
): SharedLibraryMutationResult {
|
||||
return SharedLibraryMutationResult(
|
||||
state = state.copy(bannerMessage = BannerMessage("Deleted shelf \"${shelf.name}\".")),
|
||||
state = state.copy(
|
||||
bannerMessage = BannerMessage.string(
|
||||
"banner_shelf_deleted",
|
||||
"Deleted shelf \"%1\$s\".",
|
||||
shelf.name
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords.filterNot { it.id == shelf.id },
|
||||
shelfRefs = shelfRefs.filterNot { it.shelfId == shelf.id }
|
||||
)
|
||||
|
|
@ -170,7 +200,14 @@ object SharedLibraryEditor {
|
|||
} else {
|
||||
state.libraryFilters
|
||||
},
|
||||
bannerMessage = BannerMessage("Removed folder \"${folder.name}\" and ${folderBookIds.size} book(s) from the app.")
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
"banner_folder_removed_with_book_count",
|
||||
folderBookIds.size,
|
||||
"Removed folder \"%1\$s\" and %2\$d book from the app.",
|
||||
"Removed folder \"%1\$s\" and %2\$d books from the app.",
|
||||
folder.name,
|
||||
folderBookIds.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs.filterNot { it.bookId in folderBookIds }
|
||||
|
|
@ -211,7 +248,13 @@ object SharedLibraryEditor {
|
|||
return SharedLibraryMutationResult(
|
||||
state = state.copy(
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Added ${additions.size} book(s) to shelf.")
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
"banner_books_added_to_shelf",
|
||||
additions.size,
|
||||
"%1\$d book added to shelf.",
|
||||
"%1\$d books added to shelf.",
|
||||
additions.size
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs + additions
|
||||
|
|
@ -247,7 +290,14 @@ object SharedLibraryEditor {
|
|||
rawLibraryBooks = books,
|
||||
allTags = allTags,
|
||||
selectedBookIds = emptySet(),
|
||||
bannerMessage = BannerMessage("Tagged ${selected.size} book(s) with \"${tag.name}\".")
|
||||
bannerMessage = BannerMessage.quantity(
|
||||
"banner_books_tagged_with_tag",
|
||||
selected.size,
|
||||
"%1\$d book tagged with \"%2\$s\".",
|
||||
"%1\$d books tagged with \"%2\$s\".",
|
||||
selected.size,
|
||||
tag.name
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs
|
||||
|
|
@ -265,7 +315,11 @@ object SharedLibraryEditor {
|
|||
state = state.copy(
|
||||
rawLibraryBooks = state.rawLibraryBooks.map { if (it.id == updated.id) updated.copy(timestamp = nowMillis) else it },
|
||||
allTags = (state.allTags + updated.tags).distinctBy { it.id }.sortedBy { it.name.lowercase() },
|
||||
bannerMessage = BannerMessage("Updated \"${updated.cardTitle()}\".")
|
||||
bannerMessage = BannerMessage.string(
|
||||
"banner_book_updated",
|
||||
"Updated \"%1\$s\".",
|
||||
updated.cardTitle()
|
||||
)
|
||||
),
|
||||
shelfRecords = shelfRecords,
|
||||
shelfRefs = shelfRefs
|
||||
|
|
|
|||
|
|
@ -33,13 +33,27 @@ class LibraryProjector {
|
|||
existingBookIds = state.books.mapTo(mutableSetOf()) { it.id },
|
||||
platform = ReaderPlatform.DESKTOP
|
||||
)
|
||||
val messageText = when {
|
||||
plan.importedCount > 0 -> SharedText.quantity(
|
||||
"desktop_imported_file_count_reader_support_later",
|
||||
plan.importedCount,
|
||||
"Imported %1\$d file. Reader support comes later.",
|
||||
"Imported %1\$d files. Reader support comes later.",
|
||||
plan.importedCount
|
||||
)
|
||||
plan.unsupportedCount > 0 -> SharedText.string(
|
||||
"desktop_no_supported_files_imported",
|
||||
"No supported files were imported."
|
||||
)
|
||||
else -> SharedText.string(
|
||||
"banner_duplicate_files_already_in_library",
|
||||
"Those files are already in the desktop library."
|
||||
)
|
||||
}
|
||||
return state.copy(
|
||||
books = plan.importedBooks + state.books,
|
||||
message = when {
|
||||
plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s). Reader support comes later."
|
||||
plan.unsupportedCount > 0 -> "No supported files were imported."
|
||||
else -> "Those files are already in the desktop library."
|
||||
}
|
||||
message = messageText.fallbackMessage(),
|
||||
messageText = messageText
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -326,15 +326,26 @@ fun SharedReaderScreenState.withImportedFiles(
|
|||
platform = ReaderPlatform.DESKTOP,
|
||||
nowMillis = now
|
||||
)
|
||||
val banner = when {
|
||||
plan.importedCount > 0 -> BannerMessage.quantity(
|
||||
"desktop_imported_file_count",
|
||||
plan.importedCount,
|
||||
"Imported %1\$d file.",
|
||||
"Imported %1\$d files.",
|
||||
plan.importedCount
|
||||
)
|
||||
plan.unsupportedCount > 0 -> BannerMessage.string(
|
||||
"desktop_no_supported_files_imported",
|
||||
"No supported files were imported."
|
||||
)
|
||||
else -> BannerMessage.string(
|
||||
"banner_duplicate_files_already_in_library",
|
||||
"Those files are already in the library."
|
||||
)
|
||||
}
|
||||
return copy(
|
||||
rawLibraryBooks = plan.importedBooks + rawLibraryBooks,
|
||||
bannerMessage = BannerMessage(
|
||||
when {
|
||||
plan.importedCount > 0 -> "Imported ${plan.importedCount} file(s)."
|
||||
plan.unsupportedCount > 0 -> "No supported files were imported."
|
||||
else -> "Those files are already in the library."
|
||||
}
|
||||
)
|
||||
bannerMessage = banner
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -301,6 +301,10 @@ object LocalFolderSyncEngine {
|
|||
.filter { it.sourceFolder == folderRoot && !it.path.isNullOrBlank() }
|
||||
.associateBy { it.path.orEmpty() }
|
||||
.toMutableMap()
|
||||
val scannedFilesByPath = files
|
||||
.asSequence()
|
||||
.filter { it.type in allowedTypes && it.path.isNotBlank() }
|
||||
.associateBy { it.path }
|
||||
val legacyItemsByName = booksById.values
|
||||
.asSequence()
|
||||
.filter { it.sourceFolder == folderRoot }
|
||||
|
|
@ -318,6 +322,28 @@ object LocalFolderSyncEngine {
|
|||
foundBookIds += stableId
|
||||
var existing = booksById[stableId]?.takeIf { it.sourceFolder == folderRoot }
|
||||
|
||||
if (existing != null && existing.path != file.path) {
|
||||
val collidedFile = existing.path?.let(scannedFilesByPath::get)
|
||||
val collidedStableId = collidedFile?.stableBookId
|
||||
if (
|
||||
collidedFile != null &&
|
||||
collidedStableId != null &&
|
||||
collidedStableId != stableId &&
|
||||
collidedStableId != existing.id &&
|
||||
collidedStableId !in booksById
|
||||
) {
|
||||
val oldId = existing.id
|
||||
val migratedBook = existing.copy(id = collidedStableId).withScannedFile(collidedFile)
|
||||
booksById.remove(oldId)
|
||||
booksById[collidedStableId] = migratedBook
|
||||
folderBooksByPath[collidedFile.path] = migratedBook
|
||||
idMigrations[oldId] = collidedStableId
|
||||
legacyItemsByName[existing.displayName]?.remove(existing)
|
||||
existing = booksById[stableId]?.takeIf { it.sourceFolder == folderRoot }
|
||||
stats = stats.copy(migratedBooks = stats.migratedBooks + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (existing == null) {
|
||||
val migrated = folderBooksByPath[file.path]?.takeIf { it.id != stableId }
|
||||
?: legacyItemsByName[file.name]?.firstOrNull { it.id != stableId }
|
||||
|
|
|
|||
|
|
@ -70,6 +70,27 @@ enum class ReaderTexture(val id: String, val displayName: String, val assetPath:
|
|||
|
||||
const val ReaderTextureFilePrefix = "file:"
|
||||
|
||||
val ReaderTextureImportExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp")
|
||||
|
||||
fun normalizeReaderTextureExtension(extension: String?): String? {
|
||||
val normalized = extension?.trim()?.lowercase() ?: return null
|
||||
return when (normalized) {
|
||||
"jpeg", "jpg" -> "jpg"
|
||||
"png", "webp", "gif", "bmp" -> normalized
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun readerTextureMimeTypeForExtension(extension: String): String {
|
||||
return when (extension.lowercase()) {
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"webp" -> "image/webp"
|
||||
"gif" -> "image/gif"
|
||||
"bmp" -> "image/bmp"
|
||||
else -> "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
data class ReaderTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ data class ReaderAiByokSettings(
|
|||
val recapModel: String = "",
|
||||
val ttsModel: String = "",
|
||||
val hideReaderAiFeatures: Boolean = false,
|
||||
val ttsSpeakerId: String = DEFAULT_CLOUD_TTS_SPEAKER_ID
|
||||
val ttsSpeakerId: String = DEFAULT_CLOUD_TTS_SPEAKER_ID,
|
||||
val serverBackedReaderAiFeatures: Boolean = false,
|
||||
val serverBackedCloudTts: Boolean = false
|
||||
) {
|
||||
fun sanitized(): ReaderAiByokSettings {
|
||||
val knownTextModelIds = ReaderAiModelOptions.mapTo(mutableSetOf()) { it.id }
|
||||
|
|
@ -83,8 +85,9 @@ data class ReaderAiByokSettings(
|
|||
}
|
||||
|
||||
val hasAnyAiKey: Boolean get() = geminiKey.isNotBlank() || groqKey.isNotBlank()
|
||||
val areReaderAiFeaturesAvailable: Boolean get() = !hideReaderAiFeatures && hasAnyAiKey
|
||||
val isCloudTtsAvailable: Boolean get() = geminiKey.isNotBlank() && ttsModel == GEMINI_CLOUD_TTS_MODEL_ID
|
||||
val areReaderAiFeaturesAvailable: Boolean get() = !hideReaderAiFeatures && (serverBackedReaderAiFeatures || hasAnyAiKey)
|
||||
val isByokCloudTtsAvailable: Boolean get() = geminiKey.isNotBlank() && ttsModel == GEMINI_CLOUD_TTS_MODEL_ID
|
||||
val isCloudTtsAvailable: Boolean get() = serverBackedCloudTts || isByokCloudTtsAvailable
|
||||
}
|
||||
|
||||
val ReaderAiModelOptions = listOf(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ data class SummarizationResult(
|
|||
val summary: String? = null,
|
||||
val error: String? = null,
|
||||
val cost: Double? = null,
|
||||
val freeRemaining: Int? = null
|
||||
val freeRemaining: Int? = null,
|
||||
val isCacheHit: Boolean = false
|
||||
)
|
||||
|
||||
data class RecapResult(
|
||||
|
|
|
|||
|
|
@ -49,7 +49,30 @@ interface SyncAdapter {
|
|||
interface AiAdapter {
|
||||
val isAvailable: Boolean
|
||||
suspend fun define(text: String, context: String? = null): AiDefinitionResult
|
||||
suspend fun defineStreaming(
|
||||
text: String,
|
||||
context: String? = null,
|
||||
onUpdate: (String) -> Unit
|
||||
): AiDefinitionResult {
|
||||
val result = define(text, context)
|
||||
result.definition?.takeIf { it.isNotBlank() }?.let(onUpdate)
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun summarize(text: String): SummarizationResult
|
||||
suspend fun summarizeStreaming(
|
||||
text: String,
|
||||
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
|
||||
onUpdate: (String) -> Unit
|
||||
): SummarizationResult {
|
||||
val result = summarize(text)
|
||||
if (result.cost != null || result.freeRemaining != null) {
|
||||
onUsageReceived(result.cost, result.freeRemaining)
|
||||
}
|
||||
result.summary?.takeIf { it.isNotBlank() }?.let(onUpdate)
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun recap(textBeforeCurrentLocation: String): RecapResult
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ enum class SharedSettingsAction {
|
|||
TABS_TOGGLE,
|
||||
RECENT_LIMIT,
|
||||
STRICT_FILE_FILTER,
|
||||
PDF_FILENAME_DISPLAY_NAME,
|
||||
EXTERNAL_FILE_BEHAVIOR,
|
||||
SCREEN_CAPTURE_PROTECTION,
|
||||
CUSTOM_FONTS,
|
||||
|
|
@ -579,6 +580,7 @@ data class SharedSettingsHubModel(
|
|||
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION,
|
||||
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
|
||||
SharedSettingsAction.STRICT_FILE_FILTER,
|
||||
SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME,
|
||||
SharedSettingsAction.TABS_TOGGLE,
|
||||
SharedSettingsAction.HIDE_READER_AI,
|
||||
SharedSettingsAction.CLEAR_BOOK_CACHE,
|
||||
|
|
@ -643,6 +645,7 @@ data class SharedSettingsHubInput(
|
|||
val isDebugBuild: Boolean = false,
|
||||
val isSignedIn: Boolean = false,
|
||||
val isProUser: Boolean = false,
|
||||
val accountAvailable: Boolean = true,
|
||||
val syncAvailable: Boolean = true,
|
||||
val folderSyncAvailable: Boolean = true,
|
||||
val aiSettingsAvailable: Boolean = true,
|
||||
|
|
@ -655,14 +658,18 @@ data class SharedSettingsHubInput(
|
|||
val includeRecentLimit: Boolean = true,
|
||||
val includeCustomFonts: Boolean = true,
|
||||
val includeStrictFileFilter: Boolean = true,
|
||||
val includePdfFileNameDisplayName: Boolean = false,
|
||||
val includeReaderTabs: Boolean = true,
|
||||
val includeHideReaderAi: Boolean = true,
|
||||
val includeCloudLocalDataClear: Boolean = false,
|
||||
val supportProjectAvailable: Boolean = true,
|
||||
val languageTitle: String = "Language",
|
||||
val languageSummary: String = "Choose the app language",
|
||||
val isTabsEnabled: Boolean = true,
|
||||
val isSyncEnabled: Boolean = false,
|
||||
val isFolderSyncEnabled: Boolean = false,
|
||||
val useStrictFileFilter: Boolean = false,
|
||||
val usePdfFileNameAsDisplayName: Boolean = false,
|
||||
val isScreenCaptureProtectionEnabled: Boolean = false,
|
||||
val hideReaderAi: Boolean = false
|
||||
)
|
||||
|
|
@ -743,7 +750,7 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
|
|||
SharedSettingsSectionModel(
|
||||
section = SharedSettingsSection.SYNC_ACCOUNTS,
|
||||
items = buildList {
|
||||
if (input.syncAvailable && input.featurePolicy.aiAndCloud) {
|
||||
if (input.accountAvailable && input.featurePolicy.aiAndCloud) {
|
||||
if (input.isSignedIn) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
|
|
@ -762,6 +769,8 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
|
|||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (input.syncAvailable && input.featurePolicy.aiAndCloud) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.CLOUD_SYNC,
|
||||
|
|
@ -888,8 +897,8 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
|
|||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.LANGUAGE,
|
||||
title = "Language",
|
||||
summary = "Choose the app language"
|
||||
title = input.languageTitle,
|
||||
summary = input.languageSummary
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -924,6 +933,21 @@ fun sharedSettingsHubModel(input: SharedSettingsHubInput): SharedSettingsHubMode
|
|||
)
|
||||
)
|
||||
}
|
||||
if (input.includePdfFileNameDisplayName) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
action = SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME,
|
||||
title = "Use PDF filenames",
|
||||
summary = if (input.usePdfFileNameAsDisplayName) {
|
||||
"PDF lists and tabs show filenames instead of embedded titles."
|
||||
} else {
|
||||
"PDF lists and tabs prefer embedded titles when available."
|
||||
},
|
||||
kind = SharedSettingsItemKind.TOGGLE,
|
||||
checked = input.usePdfFileNameAsDisplayName
|
||||
)
|
||||
)
|
||||
}
|
||||
if (input.includeReaderTabs) {
|
||||
add(
|
||||
SharedSettingsItemModel(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ data class SharedFeaturePolicy(
|
|||
val networkAccess: Boolean = true,
|
||||
val opdsCatalogs: Boolean = networkAccess,
|
||||
val aiAndCloud: Boolean = networkAccess,
|
||||
val byokAi: Boolean = false,
|
||||
val externalLookup: Boolean = networkAccess,
|
||||
val projectLinks: Boolean = networkAccess,
|
||||
val googleFontsDownload: Boolean = networkAccess
|
||||
|
|
@ -14,6 +15,7 @@ data class SharedFeaturePolicy(
|
|||
networkAccess = false,
|
||||
opdsCatalogs = false,
|
||||
aiAndCloud = false,
|
||||
byokAi = true,
|
||||
externalLookup = false,
|
||||
projectLinks = false,
|
||||
googleFontsDownload = false
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ data class SharedLibrarySnapshot(
|
|||
val appTextDimFactorLight: Float = 1.0f,
|
||||
val appTextDimFactorDark: Float = 1.0f,
|
||||
val appSeedColor: Color? = null,
|
||||
val appFontPreference: AppFontPreference = AppFontPreference.System,
|
||||
val customAppThemes: List<CustomAppTheme> = emptyList(),
|
||||
val readerDefaultSettings: ReaderSettings = ReaderSettings(),
|
||||
val pdfReaderDefaultSettings: ReaderSettings = ReaderSettings(themeId = "no_theme"),
|
||||
|
|
@ -53,7 +54,7 @@ data class SharedLibrarySnapshot(
|
|||
)
|
||||
|
||||
object SharedLibrarySnapshotJson {
|
||||
private const val SCHEMA_VERSION = 19
|
||||
private const val SCHEMA_VERSION = 20
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
|
|
@ -100,6 +101,10 @@ object SharedLibrarySnapshotJson {
|
|||
?: root.float("appTextDimFactor")
|
||||
?: 1.0f,
|
||||
appSeedColor = root.int("appSeedColor")?.let { Color(it) },
|
||||
appFontPreference = root["appFontPreference"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.asAppFontPreferenceOrNull()
|
||||
?: AppFontPreference.System,
|
||||
customAppThemes = root.array("customAppThemes").mapNotNull { it.asCustomAppThemeOrNull() },
|
||||
readerDefaultSettings = readerDefaultSettings.migrateLegacyDefaultReadingMode(schemaVersion),
|
||||
pdfReaderDefaultSettings = root["pdfReaderDefaultSettings"]
|
||||
|
|
@ -147,6 +152,7 @@ object SharedLibrarySnapshotJson {
|
|||
"appTextDimFactorLight" to JsonPrimitive(snapshot.appTextDimFactorLight),
|
||||
"appTextDimFactorDark" to JsonPrimitive(snapshot.appTextDimFactorDark),
|
||||
"appSeedColor" to snapshot.appSeedColor.asJson(),
|
||||
"appFontPreference" to snapshot.appFontPreference.sanitized().toJsonObject(),
|
||||
"customAppThemes" to JsonArray(snapshot.customAppThemes.map { it.toJsonObject() }),
|
||||
"readerDefaultSettings" to snapshot.readerDefaultSettings.asJson(),
|
||||
"pdfReaderDefaultSettings" to snapshot.pdfReaderDefaultSettings.asJson(),
|
||||
|
|
@ -343,6 +349,17 @@ private fun JsonElement.asCustomAppThemeOrNull(): CustomAppTheme? {
|
|||
)
|
||||
}
|
||||
|
||||
private fun JsonElement.asAppFontPreferenceOrNull(): AppFontPreference? {
|
||||
val obj = runCatching { jsonObject }.getOrNull() ?: return null
|
||||
val kind = obj.string("kind")
|
||||
?.let { runCatching { AppFontPreferenceKind.valueOf(it) }.getOrNull() }
|
||||
?: return null
|
||||
return AppFontPreference(
|
||||
kind = kind,
|
||||
customFontId = obj.string("customFontId")
|
||||
).sanitized()
|
||||
}
|
||||
|
||||
private fun BookItem.toJsonObject(): JsonObject {
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
|
|
@ -449,6 +466,16 @@ private fun CustomAppTheme.toJsonObject(): JsonObject {
|
|||
)
|
||||
}
|
||||
|
||||
private fun AppFontPreference.toJsonObject(): JsonObject {
|
||||
val sanitized = sanitized()
|
||||
return JsonObject(
|
||||
mapOf(
|
||||
"kind" to JsonPrimitive(sanitized.kind.name),
|
||||
"customFontId" to sanitized.customFontId.asJson()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Float?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
private fun Double?.asJson(): JsonElement = this?.let { JsonPrimitive(it) } ?: JsonNull
|
||||
|
|
@ -510,6 +537,10 @@ private fun JsonElement.asReaderSettingsOrNull(): ReaderSettings? {
|
|||
"pdfPageNumberOverlayVisible",
|
||||
defaults.pdfPageNumberOverlayVisible
|
||||
),
|
||||
pdfFirstPageStandaloneInSpread = obj.boolean(
|
||||
"pdfFirstPageStandaloneInSpread",
|
||||
defaults.pdfFirstPageStandaloneInSpread
|
||||
),
|
||||
seamlessChapterNavigation = obj.boolean("seamlessChapterNavigation", defaults.seamlessChapterNavigation),
|
||||
chapterTurnDragMultiplier = obj.float("chapterTurnDragMultiplier") ?: defaults.chapterTurnDragMultiplier
|
||||
)
|
||||
|
|
@ -652,6 +683,7 @@ private fun ReaderSettings?.asJson(): JsonElement {
|
|||
"pageSpreadMode" to JsonPrimitive(settings.pageSpreadMode.name),
|
||||
"pdfVerticalPageGapVisible" to JsonPrimitive(settings.pdfVerticalPageGapVisible),
|
||||
"pdfPageNumberOverlayVisible" to JsonPrimitive(settings.pdfPageNumberOverlayVisible),
|
||||
"pdfFirstPageStandaloneInSpread" to JsonPrimitive(settings.pdfFirstPageStandaloneInSpread),
|
||||
"seamlessChapterNavigation" to JsonPrimitive(settings.seamlessChapterNavigation),
|
||||
"chapterTurnDragMultiplier" to JsonPrimitive(settings.chapterTurnDragMultiplier)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ fun SharedReaderScreenState.reduce(action: AppAction): SharedReaderScreenState {
|
|||
is AppAction.AppTextDimFactorLightChanged -> copy(appTextDimFactorLight = action.factor.coerceIn(0.3f, 1.0f))
|
||||
is AppAction.AppTextDimFactorDarkChanged -> copy(appTextDimFactorDark = action.factor.coerceIn(0.3f, 1.0f))
|
||||
is AppAction.AppSeedColorChanged -> copy(appSeedColor = action.color)
|
||||
is AppAction.AppFontPreferenceChanged -> copy(appFontPreference = action.preference.sanitized())
|
||||
is AppAction.CustomAppThemeAdded -> {
|
||||
val updatedThemes = customAppThemes.filterNot { it.id == action.theme.id } + action.theme
|
||||
copy(customAppThemes = updatedThemes, appSeedColor = action.theme.seedColor)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ package com.aryan.reader.shared.opds
|
|||
|
||||
class SharedOpdsController(
|
||||
private val repository: SharedOpdsRepository,
|
||||
private val feedLoadErrorMessage: (Throwable) -> String = { error ->
|
||||
"Failed to load feed: ${error.message ?: "unknown error"}"
|
||||
},
|
||||
private val searchErrorMessage: (Throwable) -> String = { error ->
|
||||
"Failed to search catalog: ${error.message ?: "unknown error"}"
|
||||
},
|
||||
private val idFactory: () -> String
|
||||
) {
|
||||
private val urlStack = mutableListOf<String>()
|
||||
|
|
@ -9,6 +15,8 @@ class SharedOpdsController(
|
|||
var state: SharedOpdsScreenState = SharedOpdsScreenState(catalogs = repository.loadCatalogs())
|
||||
private set
|
||||
|
||||
fun hasFeedHistory(): Boolean = urlStack.size > 1
|
||||
|
||||
fun reloadCatalogs(): SharedOpdsScreenState {
|
||||
state = state.copy(catalogs = repository.loadCatalogs())
|
||||
return state
|
||||
|
|
@ -100,7 +108,7 @@ class SharedOpdsController(
|
|||
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
state = state.copy(isLoading = false, errorMessage = "Failed to search catalog: ${error.message}")
|
||||
state = state.copy(isLoading = false, errorMessage = searchErrorMessage(error))
|
||||
emit(state)
|
||||
return
|
||||
}
|
||||
|
|
@ -112,6 +120,11 @@ class SharedOpdsController(
|
|||
return state
|
||||
}
|
||||
|
||||
fun setErrorMessage(errorMessage: String?): SharedOpdsScreenState {
|
||||
state = state.copy(errorMessage = errorMessage)
|
||||
return state
|
||||
}
|
||||
|
||||
fun updateDownloadState(entryId: String, downloadState: SharedOpdsDownloadState?): SharedOpdsScreenState {
|
||||
val nextMap = if (downloadState == null) {
|
||||
state.downloadingState - entryId
|
||||
|
|
@ -154,7 +167,7 @@ class SharedOpdsController(
|
|||
}.onFailure { error ->
|
||||
state = state.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load feed: ${error.message ?: "unknown error"}"
|
||||
errorMessage = feedLoadErrorMessage(error)
|
||||
)
|
||||
}
|
||||
emit(state)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,16 @@ data class PdfPageBounds(
|
|||
val bottom: Float
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfAnnotationComment(
|
||||
val id: String,
|
||||
val parentId: String? = null,
|
||||
val author: String = "",
|
||||
val contents: String = "",
|
||||
val createdAt: Long = 0L,
|
||||
val modifiedAt: Long = 0L
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedPdfAnnotation(
|
||||
val id: String,
|
||||
|
|
@ -49,6 +59,7 @@ data class SharedPdfAnnotation(
|
|||
val boundsList: List<PdfPageBounds> = emptyList(),
|
||||
val text: String = "",
|
||||
val note: String? = null,
|
||||
val comments: List<SharedPdfAnnotationComment> = emptyList(),
|
||||
val colorArgb: Int,
|
||||
val backgroundArgb: Int = 0x00FFFFFF,
|
||||
val strokeWidth: Float = 2f,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
object PdfSpreadLayout {
|
||||
fun isTwoPageSpreadEnabled(settings: ReaderSettings): Boolean {
|
||||
return settings.pageSpreadMode == ReaderPageSpreadMode.TWO_PAGE
|
||||
}
|
||||
|
||||
fun normalizePageIndex(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): Int {
|
||||
if (pageCount <= 0) return 0
|
||||
val clamped = pageIndex.coerceIn(0, pageCount - 1)
|
||||
if (!isTwoPageSpreadEnabled(settings)) return clamped
|
||||
if (!settings.pdfFirstPageStandaloneInSpread) {
|
||||
return (clamped - (clamped % 2)).coerceIn(0, pageCount - 1)
|
||||
}
|
||||
if (clamped == 0) return 0
|
||||
val adjusted = clamped - 1
|
||||
return (1 + adjusted - (adjusted % 2)).coerceIn(0, pageCount - 1)
|
||||
}
|
||||
|
||||
fun visiblePageIndices(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): List<Int> {
|
||||
if (pageCount <= 0) return emptyList()
|
||||
val start = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
if (!isTwoPageSpreadEnabled(settings)) return listOf(start)
|
||||
if (settings.pdfFirstPageStandaloneInSpread && start == 0) return listOf(0)
|
||||
return listOf(start, start + 1).filter { it in 0 until pageCount }
|
||||
}
|
||||
|
||||
fun spreadStartPageIndices(
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): List<Int> {
|
||||
if (pageCount <= 0) return emptyList()
|
||||
if (!isTwoPageSpreadEnabled(settings)) return (0 until pageCount).toList()
|
||||
val starts = mutableListOf<Int>()
|
||||
var current = 0
|
||||
while (current in 0 until pageCount && current !in starts) {
|
||||
starts += current
|
||||
val next = nextPageIndex(current, pageCount, settings)
|
||||
if (next <= current) break
|
||||
current = next
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
fun canGoPrevious(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): Boolean {
|
||||
if (pageCount <= 1) return false
|
||||
return previousPageIndex(pageIndex, pageCount, settings) < normalizePageIndex(pageIndex, pageCount, settings)
|
||||
}
|
||||
|
||||
fun canGoNext(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): Boolean {
|
||||
if (pageCount <= 1) return false
|
||||
return nextPageIndex(pageIndex, pageCount, settings) > normalizePageIndex(pageIndex, pageCount, settings)
|
||||
}
|
||||
|
||||
fun previousPageIndex(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): Int {
|
||||
if (pageCount <= 0) return 0
|
||||
val current = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
if (!isTwoPageSpreadEnabled(settings)) {
|
||||
return (current - 1).coerceIn(0, pageCount - 1)
|
||||
}
|
||||
val target = if (settings.pdfFirstPageStandaloneInSpread && current <= 1) {
|
||||
0
|
||||
} else {
|
||||
current - 2
|
||||
}
|
||||
return normalizePageIndex(target, pageCount, settings)
|
||||
}
|
||||
|
||||
fun nextPageIndex(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): Int {
|
||||
if (pageCount <= 0) return 0
|
||||
val current = normalizePageIndex(pageIndex, pageCount, settings)
|
||||
if (!isTwoPageSpreadEnabled(settings)) {
|
||||
return (current + 1).coerceIn(0, pageCount - 1)
|
||||
}
|
||||
val target = if (settings.pdfFirstPageStandaloneInSpread && current == 0) {
|
||||
1
|
||||
} else {
|
||||
current + 2
|
||||
}
|
||||
return normalizePageIndex(target, pageCount, settings)
|
||||
}
|
||||
|
||||
fun pageRangeLabel(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): String {
|
||||
val pages = visiblePageIndices(pageIndex, pageCount.coerceAtLeast(1), settings).ifEmpty { listOf(0) }
|
||||
val first = pages.first() + 1
|
||||
val last = pages.last() + 1
|
||||
return if (first == last) "$first" else "$first-$last"
|
||||
}
|
||||
|
||||
fun progressPercent(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
settings: ReaderSettings
|
||||
): Float {
|
||||
if (pageCount <= 0) return 0f
|
||||
val visibleEnd = visiblePageIndices(pageIndex, pageCount, settings).lastOrNull()
|
||||
?: normalizePageIndex(pageIndex, pageCount, settings)
|
||||
return ((visibleEnd + 1).toFloat() / pageCount.coerceAtLeast(1)) * 100f
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
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()
|
||||
) {
|
||||
val hasPdfAnnotations: Boolean
|
||||
get() = inkAnnotations.isNotEmpty() || highlightAnnotations.isNotEmpty()
|
||||
}
|
||||
|
||||
data class SharedPdfInkAnnotationExport(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val tool: PdfInkTool,
|
||||
val points: List<PdfPagePoint>,
|
||||
val colorArgb: Int,
|
||||
val strokeWidth: Float,
|
||||
val contents: String
|
||||
)
|
||||
|
||||
data class SharedPdfHighlightAnnotationExport(
|
||||
val id: String,
|
||||
val pageIndex: Int,
|
||||
val boundsList: List<PdfPageBounds>,
|
||||
val colorArgb: Int,
|
||||
val contents: String,
|
||||
val comments: List<SharedPdfHighlightCommentExport> = emptyList()
|
||||
)
|
||||
|
||||
data class SharedPdfHighlightCommentExport(
|
||||
val id: String,
|
||||
val parentId: String?,
|
||||
val author: String,
|
||||
val contents: String,
|
||||
val createdAt: Long,
|
||||
val modifiedAt: Long
|
||||
)
|
||||
|
||||
object SharedPdfAnnotationExportMapper {
|
||||
fun build(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
resolveHighlightBounds: (SharedPdfAnnotation) -> List<PdfPageBounds> = { emptyList() }
|
||||
): SharedPdfAnnotationExportPayload {
|
||||
return SharedPdfAnnotationExportPayload(
|
||||
inkAnnotations = annotations.mapNotNull { it.toInkExportOrNull() },
|
||||
highlightAnnotations = annotations.mapNotNull { it.toHighlightExportOrNull(resolveHighlightBounds) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfAnnotation.toInkExportOrNull(): SharedPdfInkAnnotationExport? {
|
||||
if (kind != PdfAnnotationKind.INK) return null
|
||||
if (tool == PdfInkTool.NONE ||
|
||||
tool == PdfInkTool.ERASER ||
|
||||
tool == PdfInkTool.TEXT ||
|
||||
points.size < 2
|
||||
) return null
|
||||
|
||||
return SharedPdfInkAnnotationExport(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
tool = tool,
|
||||
points = points,
|
||||
colorArgb = colorArgb,
|
||||
strokeWidth = strokeWidth,
|
||||
contents = note?.trim().orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedPdfAnnotation.toHighlightExportOrNull(
|
||||
resolveHighlightBounds: (SharedPdfAnnotation) -> List<PdfPageBounds>
|
||||
): SharedPdfHighlightAnnotationExport? {
|
||||
if (kind != PdfAnnotationKind.HIGHLIGHT) return null
|
||||
|
||||
val storedBounds = boundsList.ifEmpty { listOfNotNull(bounds) }
|
||||
.mapNotNull { it.normalizedForExportOrNull() }
|
||||
val exportBounds = storedBounds.ifEmpty {
|
||||
resolveHighlightBounds(this).mapNotNull { it.normalizedForExportOrNull() }
|
||||
}
|
||||
if (exportBounds.isEmpty()) return null
|
||||
|
||||
return SharedPdfHighlightAnnotationExport(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
boundsList = exportBounds,
|
||||
colorArgb = colorArgb,
|
||||
contents = note?.trim().orEmpty(),
|
||||
comments = comments.toHighlightCommentExports(highlightId = id)
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotationComment>.toHighlightCommentExports(
|
||||
highlightId: String
|
||||
): List<SharedPdfHighlightCommentExport> {
|
||||
val sourceItems = mapIndexedNotNull { index, comment ->
|
||||
val contents = comment.contents.trim()
|
||||
if (contents.isBlank()) return@mapIndexedNotNull null
|
||||
val sourceId = comment.id.takeIf { it.isNotBlank() }
|
||||
val exportId = sourceId ?: "${highlightId}_comment_$index"
|
||||
IndexedExportComment(
|
||||
sourceId = sourceId,
|
||||
sourceParentId = comment.parentId?.takeIf { it.isNotBlank() },
|
||||
export = SharedPdfHighlightCommentExport(
|
||||
id = exportId,
|
||||
parentId = null,
|
||||
author = comment.author.trim(),
|
||||
contents = contents,
|
||||
createdAt = comment.createdAt,
|
||||
modifiedAt = comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt
|
||||
)
|
||||
)
|
||||
}
|
||||
if (sourceItems.isEmpty()) return emptyList()
|
||||
|
||||
val stableIds = mutableSetOf<String>()
|
||||
val sourceIdToExportId = mutableMapOf<String, String>()
|
||||
val uniqueItems = sourceItems.mapIndexed { index, item ->
|
||||
val uniqueId = item.export.id.uniqueCommentId(stableIds, index)
|
||||
item.sourceId?.let { sourceIdToExportId.putIfAbsent(it, uniqueId) }
|
||||
item.copy(export = item.export.copy(id = uniqueId))
|
||||
}
|
||||
val parentAwareItems = uniqueItems.map { item ->
|
||||
val parentId = item.sourceParentId?.let(sourceIdToExportId::get)
|
||||
item.copy(export = item.export.copy(parentId = parentId))
|
||||
}
|
||||
|
||||
val byParent = parentAwareItems.groupBy { it.export.parentId }
|
||||
val result = mutableListOf<SharedPdfHighlightCommentExport>()
|
||||
val emittedIds = mutableSetOf<String>()
|
||||
|
||||
fun appendThread(parentId: String?, visitedIds: Set<String>) {
|
||||
byParent[parentId].orEmpty().forEach { item ->
|
||||
val export = item.export
|
||||
if (export.id in emittedIds || export.id in visitedIds) return@forEach
|
||||
emittedIds += export.id
|
||||
result += export
|
||||
appendThread(export.id, visitedIds + export.id)
|
||||
}
|
||||
}
|
||||
|
||||
appendThread(parentId = null, visitedIds = emptySet())
|
||||
parentAwareItems.forEach { item ->
|
||||
if (item.export.id !in emittedIds) {
|
||||
val root = item.export.copy(parentId = null)
|
||||
emittedIds += root.id
|
||||
result += root
|
||||
appendThread(root.id, setOf(root.id))
|
||||
}
|
||||
}
|
||||
return result.toSingleVisiblePdfCommentThread(highlightId)
|
||||
}
|
||||
|
||||
private fun PdfPageBounds.normalizedForExportOrNull(): PdfPageBounds? {
|
||||
val normalized = PdfPageBounds(
|
||||
left = minOf(left, right),
|
||||
top = minOf(top, bottom),
|
||||
right = maxOf(left, right),
|
||||
bottom = maxOf(top, bottom)
|
||||
)
|
||||
return normalized.takeIf {
|
||||
it.left in 0f..1f &&
|
||||
it.top in 0f..1f &&
|
||||
it.right in 0f..1f &&
|
||||
it.bottom in 0f..1f &&
|
||||
it.right > it.left &&
|
||||
it.bottom > it.top
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class IndexedExportComment(
|
||||
val sourceId: String?,
|
||||
val sourceParentId: String?,
|
||||
val export: SharedPdfHighlightCommentExport
|
||||
)
|
||||
|
||||
private fun String.uniqueCommentId(usedIds: MutableSet<String>, index: Int): String {
|
||||
val base = ifBlank { "comment_$index" }
|
||||
var candidate = base
|
||||
var suffix = 2
|
||||
while (!usedIds.add(candidate)) {
|
||||
candidate = "${base}_$suffix"
|
||||
suffix += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private fun List<SharedPdfHighlightCommentExport>.toSingleVisiblePdfCommentThread(
|
||||
highlightId: String
|
||||
): List<SharedPdfHighlightCommentExport> {
|
||||
if (isEmpty()) return emptyList()
|
||||
|
||||
val threadContents = formatAsPdfCommentThread()
|
||||
if (threadContents.isBlank()) return emptyList()
|
||||
|
||||
val createdAt = mapNotNull { it.createdAt.takeIf { timestamp -> timestamp > 0L } }
|
||||
.minOrNull()
|
||||
?: 0L
|
||||
val modifiedAt = mapNotNull { comment ->
|
||||
(comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt).takeIf { it > 0L }
|
||||
}.maxOrNull() ?: createdAt
|
||||
val root = firstOrNull { it.parentId == null } ?: first()
|
||||
|
||||
return listOf(
|
||||
SharedPdfHighlightCommentExport(
|
||||
id = "${highlightId}_comments",
|
||||
parentId = null,
|
||||
author = root.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR },
|
||||
contents = threadContents,
|
||||
createdAt = createdAt,
|
||||
modifiedAt = modifiedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<SharedPdfHighlightCommentExport>.formatAsPdfCommentThread(): String {
|
||||
val commentsByParent = groupBy { it.parentId }
|
||||
val ids = map { it.id }.toSet()
|
||||
val roots = filter { it.parentId == null || it.parentId !in ids }
|
||||
val visitedIds = mutableSetOf<String>()
|
||||
val lines = mutableListOf<String>()
|
||||
|
||||
fun appendComment(comment: SharedPdfHighlightCommentExport, depth: Int) {
|
||||
if (!visitedIds.add(comment.id)) return
|
||||
|
||||
if (lines.isNotEmpty()) lines += ""
|
||||
val indent = " ".repeat(depth)
|
||||
val author = comment.author.ifBlank { DEFAULT_PDF_COMMENT_AUTHOR }
|
||||
lines += "$indent$author:"
|
||||
comment.contents.lines().forEach { line ->
|
||||
lines += "$indent$line"
|
||||
}
|
||||
commentsByParent[comment.id].orEmpty().forEach { child ->
|
||||
appendComment(child, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
roots.forEach { appendComment(it, 0) }
|
||||
forEach { comment ->
|
||||
if (comment.id !in visitedIds) appendComment(comment, 0)
|
||||
}
|
||||
|
||||
return lines.joinToString("\n").trim()
|
||||
}
|
||||
|
||||
fun SharedPdfInkAnnotationExport.pdfInkAppearancePoints(pageWidth: Float, pageHeight: Float): List<PdfPagePoint> {
|
||||
if (tool != PdfInkTool.HIGHLIGHTER || points.size < 2 || pageWidth <= 0f || pageHeight <= 0f) return points
|
||||
|
||||
// PDF ink annotations are usually rendered with rounded caps; trim chisel highlighter endpoints to match our butt-cap UI.
|
||||
val trimPdfUnits = (strokeWidth * pageWidth) * 0.65f
|
||||
if (trimPdfUnits <= 0f) return points
|
||||
|
||||
val firstTargetIndex = points.firstDistinctIndexAfter(index = 0, pageWidth = pageWidth, pageHeight = pageHeight)
|
||||
?: return points
|
||||
val lastIndex = points.lastIndex
|
||||
val lastTargetIndex = points.lastDistinctIndexBefore(index = lastIndex, pageWidth = pageWidth, pageHeight = pageHeight)
|
||||
?: return points
|
||||
val adjusted = points.toMutableList()
|
||||
val adjustedStart = adjusted[0].movedToward(points[firstTargetIndex], trimPdfUnits, pageWidth, pageHeight)
|
||||
for (pointIndex in 0 until firstTargetIndex) {
|
||||
adjusted[pointIndex] = adjustedStart
|
||||
}
|
||||
val adjustedEnd = adjusted[lastIndex].movedToward(points[lastTargetIndex], trimPdfUnits, pageWidth, pageHeight)
|
||||
for (pointIndex in lastTargetIndex + 1..lastIndex) {
|
||||
adjusted[pointIndex] = adjustedEnd
|
||||
}
|
||||
return adjusted
|
||||
}
|
||||
|
||||
private fun List<PdfPagePoint>.firstDistinctIndexAfter(
|
||||
index: Int,
|
||||
pageWidth: Float,
|
||||
pageHeight: Float
|
||||
): Int? {
|
||||
val source = getOrNull(index) ?: return null
|
||||
for (targetIndex in index + 1..lastIndex) {
|
||||
val target = this[targetIndex]
|
||||
if (source.pdfDistanceTo(target, pageWidth, pageHeight) > 0f) return targetIndex
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun List<PdfPagePoint>.lastDistinctIndexBefore(
|
||||
index: Int,
|
||||
pageWidth: Float,
|
||||
pageHeight: Float
|
||||
): Int? {
|
||||
val source = getOrNull(index) ?: return null
|
||||
for (targetIndex in index - 1 downTo 0) {
|
||||
val target = this[targetIndex]
|
||||
if (source.pdfDistanceTo(target, pageWidth, pageHeight) > 0f) return targetIndex
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun PdfPagePoint.movedToward(
|
||||
target: PdfPagePoint,
|
||||
distancePdfUnits: Float,
|
||||
pageWidth: Float,
|
||||
pageHeight: Float
|
||||
): PdfPagePoint {
|
||||
val dx = (target.x - x) * pageWidth
|
||||
val dy = (target.y - y) * pageHeight
|
||||
val length = pdfDistanceTo(target, pageWidth, pageHeight)
|
||||
if (length <= 0f) return this
|
||||
|
||||
val trim = minOf(distancePdfUnits, length * 0.49f)
|
||||
return copy(
|
||||
x = x + (dx / length) * (trim / pageWidth),
|
||||
y = y + (dy / length) * (trim / pageHeight)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PdfPagePoint.pdfDistanceTo(target: PdfPagePoint, pageWidth: Float, pageHeight: Float): Float {
|
||||
val dx = (target.x - x) * pageWidth
|
||||
val dy = (target.y - y) * pageHeight
|
||||
return sqrt((dx * dx + dy * dy).toDouble()).toFloat()
|
||||
}
|
||||
|
|
@ -189,6 +189,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
boundsList = boundsList,
|
||||
text = obj.string("text").orEmpty(),
|
||||
note = obj.string("note"),
|
||||
comments = obj.array("comments").toSharedPdfAnnotationComments(),
|
||||
colorArgb = SharedPdfAndroidHighlightColors.argbForName(colorName),
|
||||
rangeStartIndex = rangeStart,
|
||||
rangeEndIndex = inclusiveRangeEnd
|
||||
|
|
@ -270,6 +271,7 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
put("rangeStart", JsonPrimitive(rangeStart))
|
||||
put("rangeEnd", JsonPrimitive(rangeEnd))
|
||||
annotation.note?.takeIf { it.isNotBlank() }?.let { put("note", JsonPrimitive(it)) }
|
||||
annotation.comments.toJsonArrayOrNull()?.let { put("comments", it) }
|
||||
put("bounds", JsonArray(emptyList()))
|
||||
}
|
||||
)
|
||||
|
|
@ -326,6 +328,47 @@ object SharedPdfAnnotationSidecarCodec {
|
|||
|
||||
private fun JsonObject.objectValue(name: String): JsonObject? = this[name]?.jsonObjectOrNull()
|
||||
|
||||
private fun JsonArray?.toSharedPdfAnnotationComments(): List<SharedPdfAnnotationComment> {
|
||||
return this?.mapNotNull { element ->
|
||||
val obj = element.jsonObjectOrNull() ?: return@mapNotNull null
|
||||
val contents = obj.string("contents")
|
||||
?: obj.string("text")
|
||||
?: obj.string("comment")
|
||||
?: return@mapNotNull null
|
||||
SharedPdfAnnotationComment(
|
||||
id = obj.string("id") ?: stableAnnotationId("comment", element),
|
||||
parentId = obj.string("parentId") ?: obj.string("inReplyTo"),
|
||||
author = obj.string("author").orEmpty(),
|
||||
contents = contents,
|
||||
createdAt = obj.long("createdAt") ?: obj.long("created") ?: 0L,
|
||||
modifiedAt = obj.long("modifiedAt")
|
||||
?: obj.long("modified")
|
||||
?: obj.long("createdAt")
|
||||
?: obj.long("created")
|
||||
?: 0L
|
||||
)
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
private fun List<SharedPdfAnnotationComment>.toJsonArrayOrNull(): JsonArray? {
|
||||
val comments = mapNotNull { comment ->
|
||||
val contents = comment.contents.trim()
|
||||
if (contents.isBlank()) return@mapNotNull null
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put("id", JsonPrimitive(comment.id))
|
||||
comment.parentId?.takeIf { it.isNotBlank() }?.let { put("parentId", JsonPrimitive(it)) }
|
||||
comment.author.takeIf { it.isNotBlank() }?.let { put("author", JsonPrimitive(it)) }
|
||||
put("contents", JsonPrimitive(contents))
|
||||
if (comment.createdAt > 0L) put("createdAt", JsonPrimitive(comment.createdAt))
|
||||
val modifiedAt = comment.modifiedAt.takeIf { it > 0L } ?: comment.createdAt
|
||||
if (modifiedAt > 0L) put("modifiedAt", JsonPrimitive(modifiedAt))
|
||||
}
|
||||
)
|
||||
}
|
||||
return JsonArray(comments).takeIf { comments.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? {
|
||||
return runCatching { this[name]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull }
|
||||
.getOrNull()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,397 @@
|
|||
package com.aryan.reader.shared.pdf
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class SharedPdfReflowTextSpan(
|
||||
val text: String,
|
||||
val size: Float,
|
||||
val isBold: Boolean,
|
||||
val isItalic: Boolean
|
||||
)
|
||||
|
||||
sealed interface SharedPdfReflowPageElement {
|
||||
val yPos: Float
|
||||
}
|
||||
|
||||
data class SharedPdfReflowTextElement(
|
||||
val line: SharedPdfReflowTextLine,
|
||||
override val yPos: Float = line.yPos
|
||||
) : SharedPdfReflowPageElement
|
||||
|
||||
data class SharedPdfReflowImageElement(
|
||||
val base64Data: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
override val yPos: Float,
|
||||
val mimeType: String = "image/jpeg"
|
||||
) : SharedPdfReflowPageElement
|
||||
|
||||
data class SharedPdfReflowTextLine(
|
||||
val spans: List<SharedPdfReflowTextSpan>,
|
||||
val yPos: Float,
|
||||
val charCount: Int
|
||||
)
|
||||
|
||||
data class SharedPdfReflowPage(
|
||||
val pageNumber: Int,
|
||||
val elements: List<SharedPdfReflowPageElement>
|
||||
)
|
||||
|
||||
object SharedPdfReflowHtml {
|
||||
fun buildGlobalHtmlHeader(): String = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: sans-serif; line-height: 1.65; padding: 1em; max-width: 100%; margin: 0; }
|
||||
h1 { font-size: 1.9em; font-weight: bold; margin: 1.2em 0 0.4em; }
|
||||
h2 { font-size: 1.55em; font-weight: bold; margin: 1.1em 0 0.35em; }
|
||||
h3 { font-size: 1.3em; font-weight: bold; margin: 1.0em 0 0.3em; }
|
||||
h4 { font-size: 1.1em; font-weight: bold; margin: 0.9em 0 0.25em; }
|
||||
p { margin: 0.5em 0; }
|
||||
ul, ol { padding-left: 1.5em; margin: 0.5em 0; }
|
||||
li { margin-bottom: 0.2em; }
|
||||
hr { border: none; border-top: 1px solid currentColor; opacity: 0.25; margin: 1.4em 0; }
|
||||
.page-section { margin-bottom: 0.5em; }
|
||||
.page-marker { opacity: 0.4; font-size: 0.72em; margin-bottom: 1.2em; letter-spacing: 0.04em; }
|
||||
.page-divider { border: none; border-top: 1px solid currentColor; opacity: 0.12; margin: 2em 0 1.5em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
""".trimIndent() + "\n"
|
||||
|
||||
fun buildGlobalHtmlFooter(): String = "\n</body>\n</html>\n"
|
||||
|
||||
fun buildPageHtml(
|
||||
page: SharedPdfReflowPage,
|
||||
headerFooterStrings: Set<String> = emptySet()
|
||||
): String {
|
||||
if (page.elements.isEmpty()) return buildEmptyPageSection(page.pageNumber)
|
||||
|
||||
val textElements = page.elements.filterIsInstance<SharedPdfReflowTextElement>()
|
||||
if (textElements.isEmpty()) {
|
||||
return buildPageHtmlFromElements(
|
||||
pageNumber = page.pageNumber,
|
||||
elements = page.elements,
|
||||
headerFooterStrings = headerFooterStrings,
|
||||
baseSize = 12f,
|
||||
wrapThreshold = 64
|
||||
)
|
||||
}
|
||||
|
||||
val sizeFreq = HashMap<Int, Int>()
|
||||
textElements.forEach { textElement ->
|
||||
textElement.line.spans.forEach { span ->
|
||||
val size = span.size.roundToInt().coerceAtLeast(1)
|
||||
sizeFreq[size] = (sizeFreq[size] ?: 0) + span.text.length
|
||||
}
|
||||
}
|
||||
val baseSize = sizeFreq.maxByOrNull { it.value }?.key?.toFloat() ?: 12f
|
||||
|
||||
val lineLengths = textElements
|
||||
.filter { it.line.charCount > 10 }
|
||||
.map { it.line.charCount }
|
||||
.sorted()
|
||||
val typicalLineLen = if (lineLengths.isNotEmpty()) {
|
||||
lineLengths[(lineLengths.size * 0.80).toInt().coerceAtMost(lineLengths.size - 1)]
|
||||
} else {
|
||||
80
|
||||
}
|
||||
val wrapThreshold = (typicalLineLen * 0.80).toInt()
|
||||
|
||||
return buildPageHtmlFromElements(
|
||||
pageNumber = page.pageNumber,
|
||||
elements = page.elements,
|
||||
headerFooterStrings = headerFooterStrings,
|
||||
baseSize = baseSize,
|
||||
wrapThreshold = wrapThreshold
|
||||
)
|
||||
}
|
||||
|
||||
fun buildEmptyPageSection(pageNumber: Int): String =
|
||||
"<section class=\"page-section\">\n" +
|
||||
"<p class=\"page-marker\">-- Page $pageNumber --</p>\n" +
|
||||
"<p><em>(No text on this page)</em></p>\n</section>\n"
|
||||
|
||||
fun buildFallbackPageSection(pageNumber: Int, rawText: String): String =
|
||||
"<section class=\"page-section\">\n" +
|
||||
"<p class=\"page-marker\">-- Page $pageNumber --</p>\n" +
|
||||
"<p>${rawText.escapeSharedPdfReflowHtml()}</p>\n</section>\n"
|
||||
|
||||
fun detectRepeatingHeaderFooter(samplePageLines: List<List<String>>): Set<String> {
|
||||
if (samplePageLines.size < 5) return emptySet()
|
||||
val frequency = HashMap<String, Int>()
|
||||
samplePageLines.forEach { lines ->
|
||||
val edgeLines = lines
|
||||
.map { it.trim() }
|
||||
.filter { it.length > 2 }
|
||||
.let { cleanLines -> cleanLines.take(2) + cleanLines.takeLast(2) }
|
||||
edgeLines.forEach { line ->
|
||||
frequency[line] = (frequency[line] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
return frequency.filter { it.value >= 3 }.keys
|
||||
}
|
||||
|
||||
private fun buildPageHtmlFromElements(
|
||||
pageNumber: Int,
|
||||
elements: List<SharedPdfReflowPageElement>,
|
||||
headerFooterStrings: Set<String>,
|
||||
baseSize: Float,
|
||||
wrapThreshold: Int
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("<section class=\"page-section\">\n")
|
||||
sb.append("<p class=\"page-marker\">-- Page $pageNumber --</p>\n")
|
||||
|
||||
var inParagraph = false
|
||||
var inUl = false
|
||||
var inOl = false
|
||||
var inLi = false
|
||||
|
||||
fun closeParagraph() {
|
||||
if (inParagraph) {
|
||||
sb.append("</p>\n")
|
||||
inParagraph = false
|
||||
}
|
||||
}
|
||||
|
||||
fun closeLi() {
|
||||
if (inLi) {
|
||||
sb.append("</li>\n")
|
||||
inLi = false
|
||||
}
|
||||
}
|
||||
|
||||
fun closeList() {
|
||||
closeLi()
|
||||
if (inUl) {
|
||||
sb.append("</ul>\n")
|
||||
inUl = false
|
||||
}
|
||||
if (inOl) {
|
||||
sb.append("</ol>\n")
|
||||
inOl = false
|
||||
}
|
||||
}
|
||||
|
||||
for ((index, element) in elements.withIndex()) {
|
||||
when (element) {
|
||||
is SharedPdfReflowImageElement -> {
|
||||
closeParagraph()
|
||||
closeList()
|
||||
sb.append("<div style=\"text-align:center; margin: 1.5em 0;\">\n")
|
||||
sb.append(
|
||||
"<img src=\"data:${element.mimeType};base64,${element.base64Data}\" " +
|
||||
"style=\"max-width:100%; height:auto; border-radius: 6px;\"/>\n"
|
||||
)
|
||||
sb.append("</div>\n")
|
||||
}
|
||||
|
||||
is SharedPdfReflowTextElement -> {
|
||||
val line = element.line
|
||||
val lineText = line.spans.joinToString("") { it.text }
|
||||
val trimmed = lineText.trim()
|
||||
|
||||
if (trimmed.isEmpty() || headerFooterStrings.any { it.equals(trimmed, ignoreCase = true) }) {
|
||||
closeParagraph()
|
||||
continue
|
||||
}
|
||||
|
||||
val maxSize = line.spans
|
||||
.filter { it.text.isNotBlank() }
|
||||
.maxOfOrNull { it.size }
|
||||
?: baseSize
|
||||
val headingLevel = when {
|
||||
maxSize > baseSize * 1.6f -> 1
|
||||
maxSize > baseSize * 1.28f -> 2
|
||||
maxSize > baseSize * 1.10f -> 3
|
||||
maxSize > baseSize * 1.04f -> 4
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val lineLen = trimmed.length
|
||||
val isShort = lineLen < 60
|
||||
val isAllCaps = isShort &&
|
||||
lineLen >= 3 &&
|
||||
trimmed.any { it.isLetter() } &&
|
||||
trimmed.all { it.isUpperCase() || !it.isLetter() } &&
|
||||
!trimmed.endsWith(".")
|
||||
val isBullet = trimmed.startsWith("* ") ||
|
||||
trimmed.startsWith("- ") && trimmed.length > 2 && !trimmed.startsWith("--") ||
|
||||
trimmed.startsWith("\u2022") ||
|
||||
trimmed.startsWith("\u25AA") ||
|
||||
trimmed.startsWith("\u25E6") ||
|
||||
trimmed.startsWith("\u2013")
|
||||
val numberedMatch = Regex("""^(\d{1,3}[.)]\s|\p{L}[.)]\s)""").containsMatchIn(trimmed)
|
||||
val isHr = isShort &&
|
||||
trimmed.length >= 3 &&
|
||||
trimmed.all { it == '-' || it == '=' || it == '_' || it.isWhitespace() }
|
||||
|
||||
val effectiveHeading = when {
|
||||
headingLevel > 0 -> headingLevel
|
||||
isAllCaps && !isBullet && !numberedMatch -> 2
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val nextTextElement = elements
|
||||
.drop(index + 1)
|
||||
.firstOrNull {
|
||||
it is SharedPdfReflowTextElement &&
|
||||
it.line.spans.joinToString("") { span -> span.text }.isNotBlank()
|
||||
} as? SharedPdfReflowTextElement
|
||||
val nextLineStartsNewThought = nextTextElement != null &&
|
||||
nextTextElement.line.spans.joinToString("") { it.text }
|
||||
.trimStart()
|
||||
.let { it.startsWith("\"") || it.startsWith("-") || it.startsWith("\u201C") }
|
||||
val shouldBreakParagraph = effectiveHeading > 0 ||
|
||||
isBullet ||
|
||||
numberedMatch ||
|
||||
isHr ||
|
||||
lineLen < wrapThreshold ||
|
||||
trimmed.last().let { it == '.' || it == '!' || it == '?' || it == ':' || it == '"' || it == '\u201D' } ||
|
||||
nextLineStartsNewThought
|
||||
|
||||
when {
|
||||
isHr -> {
|
||||
closeParagraph()
|
||||
closeList()
|
||||
sb.append("<hr>\n")
|
||||
}
|
||||
|
||||
effectiveHeading > 0 -> {
|
||||
closeParagraph()
|
||||
closeList()
|
||||
val tag = "h${effectiveHeading.coerceIn(1, 4)}"
|
||||
sb.append("<$tag>${renderSpans(line.spans, insideHeading = true)}</$tag>\n")
|
||||
}
|
||||
|
||||
isBullet -> {
|
||||
closeParagraph()
|
||||
closeLi()
|
||||
if (inOl) {
|
||||
sb.append("</ol>\n")
|
||||
inOl = false
|
||||
}
|
||||
if (!inUl) {
|
||||
sb.append("<ul>\n")
|
||||
inUl = true
|
||||
}
|
||||
val content = trimmed
|
||||
.removePrefix("* ")
|
||||
.removePrefix("\u2022")
|
||||
.removePrefix("\u25AA")
|
||||
.removePrefix("\u25E6")
|
||||
.removePrefix("\u2013")
|
||||
.removePrefix("- ")
|
||||
.trim()
|
||||
sb.append("<li>${content.escapeSharedPdfReflowHtml()}")
|
||||
inLi = true
|
||||
}
|
||||
|
||||
numberedMatch -> {
|
||||
closeParagraph()
|
||||
closeLi()
|
||||
if (inUl) {
|
||||
sb.append("</ul>\n")
|
||||
inUl = false
|
||||
}
|
||||
if (!inOl) {
|
||||
sb.append("<ol>\n")
|
||||
inOl = true
|
||||
}
|
||||
val content = trimmed.substringAfter(" ").trim()
|
||||
sb.append("<li>${content.escapeSharedPdfReflowHtml()}")
|
||||
inLi = true
|
||||
}
|
||||
|
||||
shouldBreakParagraph -> {
|
||||
if (inLi) {
|
||||
sb.append(" ").append(renderSpans(line.spans))
|
||||
closeLi()
|
||||
} else {
|
||||
closeList()
|
||||
if (!inParagraph) {
|
||||
sb.append("<p>")
|
||||
inParagraph = true
|
||||
}
|
||||
sb.append(renderSpans(line.spans))
|
||||
closeParagraph()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (inLi) {
|
||||
sb.append(" ").append(renderSpans(line.spans))
|
||||
} else {
|
||||
closeList()
|
||||
if (!inParagraph) {
|
||||
sb.append("<p>")
|
||||
inParagraph = true
|
||||
} else {
|
||||
sb.append(" ")
|
||||
}
|
||||
sb.append(renderSpans(line.spans))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeParagraph()
|
||||
closeList()
|
||||
sb.append("</section>\n")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun renderSpans(spans: List<SharedPdfReflowTextSpan>, insideHeading: Boolean = false): String {
|
||||
val sb = StringBuilder()
|
||||
for (span in spans) {
|
||||
val escaped = span.text.escapeSharedPdfReflowHtml()
|
||||
if (escaped.isBlank()) {
|
||||
sb.append(escaped)
|
||||
continue
|
||||
}
|
||||
|
||||
val leadCount = escaped.length - escaped.trimStart().length
|
||||
val trailCount = escaped.length - escaped.trimEnd().length
|
||||
val pre = escaped.take(leadCount)
|
||||
val post = if (trailCount > 0) escaped.takeLast(trailCount) else ""
|
||||
val mid = escaped.substring(leadCount, escaped.length - trailCount)
|
||||
|
||||
if (mid.isEmpty()) {
|
||||
sb.append(escaped)
|
||||
continue
|
||||
}
|
||||
|
||||
sb.append(pre)
|
||||
if (!insideHeading) {
|
||||
when {
|
||||
span.isBold && span.isItalic -> sb.append("<strong><em>")
|
||||
span.isBold -> sb.append("<strong>")
|
||||
span.isItalic -> sb.append("<em>")
|
||||
}
|
||||
}
|
||||
sb.append(mid)
|
||||
if (!insideHeading) {
|
||||
when {
|
||||
span.isBold && span.isItalic -> sb.append("</em></strong>")
|
||||
span.isBold -> sb.append("</strong>")
|
||||
span.isItalic -> sb.append("</em>")
|
||||
}
|
||||
}
|
||||
sb.append(post)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.escapeSharedPdfReflowHtml(): String = this
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
|
@ -515,6 +515,28 @@ private fun AnnotatedString.withRestoredTrailingSharedPdfPageBreak(shouldRestore
|
|||
return this + AnnotatedString(SHARED_PDF_PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
|
||||
internal fun sharedPdfRichTextInsertionIndexForPage(
|
||||
insertPageIndex: Int,
|
||||
pageLayouts: List<SharedPdfRichPageLayout>,
|
||||
textLength: Int
|
||||
): Int {
|
||||
val rawIndex = if (insertPageIndex <= 0) {
|
||||
0
|
||||
} else {
|
||||
pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: textLength
|
||||
}
|
||||
return rawIndex.coerceIn(0, textLength)
|
||||
}
|
||||
|
||||
internal fun sharedPdfRichTextBlankInsertBreakCount(text: String, insertionCharIndex: Int): Int {
|
||||
val safeIndex = insertionCharIndex.coerceIn(0, text.length)
|
||||
if (safeIndex == 0 || safeIndex == text.length) return 1
|
||||
|
||||
val hasBoundaryBreakBefore = text.getOrNull(safeIndex - 1) == SHARED_PDF_PAGE_BREAK_CHAR
|
||||
val hasBoundaryBreakAfter = text.getOrNull(safeIndex) == SHARED_PDF_PAGE_BREAK_CHAR
|
||||
return if (hasBoundaryBreakBefore || hasBoundaryBreakAfter) 1 else 2
|
||||
}
|
||||
|
||||
internal fun List<SharedPdfRichPageLayout>.withTrailingBlankRichTextPageIfNeeded(
|
||||
globalText: AnnotatedString,
|
||||
pageHeightPx: Float
|
||||
|
|
@ -933,25 +955,55 @@ class SharedPdfRichTextController(
|
|||
scope.launch {
|
||||
forceSyncAndClear()
|
||||
val original = globalTextFieldValue.annotatedString
|
||||
val insertionCharIndex = if (insertPageIndex == 0) {
|
||||
0
|
||||
} else {
|
||||
pageLayouts.find { it.pageIndex == insertPageIndex - 1 }?.globalEndIndex ?: original.length
|
||||
}
|
||||
val safeIndex = insertionCharIndex.coerceIn(0, original.length)
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.append(original.subSequence(0, safeIndex))
|
||||
repeat(count) { builder.append(SHARED_PDF_PAGE_BREAK_CHAR.toString()) }
|
||||
builder.append(original.subSequence(safeIndex, original.length))
|
||||
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(safeIndex + count))
|
||||
debouncedSave(globalTextFieldValue)
|
||||
repaginate(dirtyStartIndex = safeIndex)
|
||||
val safeIndex = sharedPdfRichTextInsertionIndexForPage(
|
||||
insertPageIndex = insertPageIndex,
|
||||
pageLayouts = pageLayouts,
|
||||
textLength = original.length
|
||||
)
|
||||
insertPageBreaksIntoGlobalText(original, safeIndex, count)
|
||||
SharedPdfRichTextLog.d(
|
||||
"controller.insertPageBreak inserted index=$safeIndex newLen=${globalTextFieldValue.text.length}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun insertBlankPageAt(insertPageIndex: Int) {
|
||||
SharedPdfRichTextLog.d("controller.insertBlankPage requested page=$insertPageIndex")
|
||||
scope.launch {
|
||||
forceSyncAndClear()
|
||||
val original = globalTextFieldValue.annotatedString
|
||||
val safeIndex = sharedPdfRichTextInsertionIndexForPage(
|
||||
insertPageIndex = insertPageIndex,
|
||||
pageLayouts = pageLayouts,
|
||||
textLength = original.length
|
||||
)
|
||||
val requiredBreaks = sharedPdfRichTextBlankInsertBreakCount(
|
||||
text = original.text,
|
||||
insertionCharIndex = safeIndex
|
||||
)
|
||||
insertPageBreaksIntoGlobalText(original, safeIndex, requiredBreaks)
|
||||
SharedPdfRichTextLog.d(
|
||||
"controller.insertBlankPage inserted index=$safeIndex breaks=$requiredBreaks newLen=${globalTextFieldValue.text.length}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertPageBreaksIntoGlobalText(
|
||||
original: AnnotatedString,
|
||||
safeIndex: Int,
|
||||
count: Int
|
||||
) {
|
||||
val safeCount = count.coerceAtLeast(0)
|
||||
if (safeCount == 0) return
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.append(original.subSequence(0, safeIndex))
|
||||
repeat(safeCount) { builder.append(SHARED_PDF_PAGE_BREAK_CHAR.toString()) }
|
||||
builder.append(original.subSequence(safeIndex, original.length))
|
||||
globalTextFieldValue = TextFieldValue(builder.toAnnotatedString(), TextRange(safeIndex + safeCount))
|
||||
debouncedSave(globalTextFieldValue)
|
||||
repaginate(dirtyStartIndex = safeIndex)
|
||||
}
|
||||
|
||||
fun deleteTextOnPage(pageIndex: Int) {
|
||||
SharedPdfRichTextLog.d("controller.deleteTextOnPage requested page=$pageIndex")
|
||||
scope.launch {
|
||||
|
|
|
|||
|
|
@ -774,8 +774,20 @@ object ReaderHtmlDocumentBuilder {
|
|||
if (activeStart === undefined || activeStart === null) {
|
||||
activeStart = numberAttribute(document.body, 'data-reader-active-start-offset', null);
|
||||
}
|
||||
var chapter = readerHostForLocator(chapterIndex, activeStart, locator.endOffset);
|
||||
var requestedPageIndex = parseInt(locator.pageIndex, 10);
|
||||
var chapter = Number.isFinite(requestedPageIndex)
|
||||
? document.querySelector('[data-reader-page-index="' + selectorValue(requestedPageIndex) + '"]')
|
||||
: null;
|
||||
if (!chapter) chapter = readerHostForLocator(chapterIndex, activeStart, locator.endOffset);
|
||||
if (!chapter) return;
|
||||
var exactCfi = locator.cfi
|
||||
? chapter.querySelector('[data-reader-cfi="' + selectorValue(locator.cfi) + '"]')
|
||||
: null;
|
||||
if (exactCfi) {
|
||||
var cfiRect = exactCfi.getBoundingClientRect();
|
||||
window.scrollTo({ top: Math.max(0, cfiRect.top + window.scrollY - 24), left: 0, behavior: 'auto' });
|
||||
return;
|
||||
}
|
||||
var exact = activeStart === null
|
||||
? null
|
||||
: chapter.querySelector('[data-reader-start-offset="' + selectorValue(activeStart) + '"]');
|
||||
|
|
@ -2767,7 +2779,7 @@ object ReaderHtmlDocumentBuilder {
|
|||
val tag = if (isOrdered) "ol" else "ul"
|
||||
"<$tag${styleAttribute()}>${items.joinToString("") { it.toHtml(searchQuery, searchOptions) }}</$tag>"
|
||||
}
|
||||
is SemanticImage -> "<figure${styleAttribute()}><img src=\"${path.escapeHtml()}\" alt=\"${altText.orEmpty().escapeHtml()}\"${imageSizeAttribute()}></figure>"
|
||||
is SemanticImage -> "<figure${imageAnchorAttributes()}${styleAttribute()}><img src=\"${path.escapeHtml()}\" alt=\"${altText.orEmpty().escapeHtml()}\"${imageSizeAttribute()}></figure>"
|
||||
is SemanticMath -> svgContent ?: "<pre${styleAttribute()}>${altText.orEmpty().highlightAndEscape(searchQuery, searchOptions)}</pre>"
|
||||
is SemanticSpacer -> if (isExplicitLineBreak) "<br>" else "<div${styleAttribute("height:1em")}></div>"
|
||||
is SemanticTable -> rows.joinToString("", "<table${styleAttribute()}><tbody>", "</tbody></table>") { row ->
|
||||
|
|
@ -2852,6 +2864,18 @@ object ReaderHtmlDocumentBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
private fun SemanticImage.imageAnchorAttributes(): String {
|
||||
return buildString {
|
||||
append(" data-reader-block-index=\"$blockIndex\"")
|
||||
elementId?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" id=\"${it.escapeHtml()}\" data-reader-element-id=\"${it.escapeHtml()}\"")
|
||||
}
|
||||
cfi?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" data-reader-cfi=\"${it.escapeHtml()}\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SemanticTextBlock.textHtml(
|
||||
searchQuery: String,
|
||||
searchOptions: ReaderSearchOptions
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
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.SemanticTable
|
||||
import com.aryan.reader.paginatedreader.SemanticTextBlock
|
||||
import com.aryan.reader.paginatedreader.SemanticWrappingBlock
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
|
||||
data class ReaderImageReference(
|
||||
val id: String,
|
||||
val index: Int,
|
||||
val source: String,
|
||||
val altText: String?,
|
||||
val chapterIndex: Int,
|
||||
val chapterTitle: String,
|
||||
val blockIndex: Int,
|
||||
val cfi: String?,
|
||||
val intrinsicWidth: Float?,
|
||||
val intrinsicHeight: Float?,
|
||||
val locator: ReaderLocator
|
||||
) {
|
||||
val displayTitle: String
|
||||
get() = altText?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: sourceName()?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
|
||||
?: "Image ${index + 1}"
|
||||
|
||||
val dimensionLabel: String?
|
||||
get() {
|
||||
val width = intrinsicWidth?.takeIf { it > 0f }?.toInt()
|
||||
val height = intrinsicHeight?.takeIf { it > 0f }?.toInt()
|
||||
return if (width != null && height != null) "${width}x$height" else null
|
||||
}
|
||||
|
||||
fun sourceName(): String? {
|
||||
if (source.startsWith("data:", ignoreCase = true)) return null
|
||||
return source
|
||||
.substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.replace('\\', '/')
|
||||
.substringAfterLast('/')
|
||||
.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun suggestedDownloadFileName(): String {
|
||||
val extension = source.readerImageExtension()
|
||||
val sourceName = sourceName()
|
||||
val base = altText?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: sourceName?.substringBeforeLast('.')?.takeIf { it.isNotBlank() }
|
||||
?: "image-${index + 1}"
|
||||
val safeBase = base.sanitizedReaderImageFileBase().ifBlank { "image-${index + 1}" }
|
||||
val safeExtension = extension?.takeIf { it.isNotBlank() } ?: "png"
|
||||
return "$safeBase.$safeExtension"
|
||||
}
|
||||
}
|
||||
|
||||
fun SharedEpubBook.readerImageReferences(pages: List<ReaderPage> = emptyList()): List<ReaderImageReference> {
|
||||
val references = mutableListOf<ReaderImageReference>()
|
||||
chapters.forEachIndexed { chapterIndex, chapter ->
|
||||
val markers = chapter.semanticBlocks.readerImageMarkers()
|
||||
markers.forEachIndexed { markerIndex, marker ->
|
||||
if (marker !is ReaderImageMarker.Image) return@forEachIndexed
|
||||
val image = marker.image
|
||||
val anchorOffset = markers.nearestTextOffsetFor(markerIndex)
|
||||
val pageIndex = pages.findPageIndexForImage(chapterIndex, image)
|
||||
?: anchorOffset?.let { offset ->
|
||||
pages.firstOrNull { page ->
|
||||
page.chapterIndex == chapterIndex && offset in page.startOffset..page.endOffset
|
||||
}?.pageIndex
|
||||
}
|
||||
?: pages.firstOrNull { it.chapterIndex == chapterIndex }?.pageIndex
|
||||
val startOffset = if (pageIndex == null) anchorOffset else null
|
||||
val endOffset = startOffset
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = chapterIndex,
|
||||
chapterId = chapter.id,
|
||||
href = chapter.baseHref,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = startOffset,
|
||||
endOffset = endOffset,
|
||||
textQuote = image.altText?.takeIf { it.isNotBlank() },
|
||||
cfi = image.cfi
|
||||
)
|
||||
val index = references.size
|
||||
references += ReaderImageReference(
|
||||
id = "image:$chapterIndex:${image.blockIndex}:${image.cfi.orEmpty()}:${image.path.hashCode()}:$index",
|
||||
index = index,
|
||||
source = image.path,
|
||||
altText = image.altText,
|
||||
chapterIndex = chapterIndex,
|
||||
chapterTitle = chapter.title,
|
||||
blockIndex = image.blockIndex,
|
||||
cfi = image.cfi,
|
||||
intrinsicWidth = image.intrinsicWidth,
|
||||
intrinsicHeight = image.intrinsicHeight,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
private sealed interface ReaderImageMarker {
|
||||
data class Text(val startOffset: Int, val endOffset: Int) : ReaderImageMarker
|
||||
data class Image(val image: SemanticImage) : ReaderImageMarker
|
||||
}
|
||||
|
||||
private fun List<SemanticBlock>.readerImageMarkers(): List<ReaderImageMarker> {
|
||||
return flatMap { it.readerImageMarkers() }
|
||||
}
|
||||
|
||||
private fun SemanticBlock.readerImageMarkers(): List<ReaderImageMarker> {
|
||||
return when (this) {
|
||||
is SemanticTextBlock -> listOf(
|
||||
ReaderImageMarker.Text(
|
||||
startOffset = startCharOffsetInSource,
|
||||
endOffset = startCharOffsetInSource + text.length
|
||||
)
|
||||
)
|
||||
is SemanticImage -> listOf(ReaderImageMarker.Image(this))
|
||||
is SemanticList -> items.flatMap { it.readerImageMarkers() }
|
||||
is SemanticTable -> rows.flatMap { row -> row.flatMap { cell -> cell.content.readerImageMarkers() } }
|
||||
is SemanticFlexContainer -> children.readerImageMarkers()
|
||||
is SemanticWrappingBlock -> listOf(ReaderImageMarker.Image(floatedImage)) +
|
||||
paragraphsToWrap.flatMap { it.readerImageMarkers() }
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReaderImageMarker>.nearestTextOffsetFor(index: Int): Int? {
|
||||
val previous = asSequence()
|
||||
.take(index)
|
||||
.filterIsInstance<ReaderImageMarker.Text>()
|
||||
.lastOrNull()
|
||||
?.endOffset
|
||||
if (previous != null) return previous
|
||||
return asSequence()
|
||||
.drop(index + 1)
|
||||
.filterIsInstance<ReaderImageMarker.Text>()
|
||||
.firstOrNull()
|
||||
?.startOffset
|
||||
}
|
||||
|
||||
private fun List<ReaderPage>.findPageIndexForImage(chapterIndex: Int, image: SemanticImage): Int? {
|
||||
return firstOrNull { page ->
|
||||
page.chapterIndex == chapterIndex && page.semanticBlocks.any { it.containsReaderImage(image) }
|
||||
}?.pageIndex
|
||||
}
|
||||
|
||||
private fun SemanticBlock.containsReaderImage(target: SemanticImage): Boolean {
|
||||
return when (this) {
|
||||
is SemanticImage -> sameReaderImageAs(target)
|
||||
is SemanticList -> items.any { it.containsReaderImage(target) }
|
||||
is SemanticTable -> rows.any { row -> row.any { cell -> cell.content.any { it.containsReaderImage(target) } } }
|
||||
is SemanticFlexContainer -> children.any { it.containsReaderImage(target) }
|
||||
is SemanticWrappingBlock -> floatedImage.sameReaderImageAs(target) ||
|
||||
paragraphsToWrap.any { it.containsReaderImage(target) }
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun SemanticImage.sameReaderImageAs(other: SemanticImage): Boolean {
|
||||
val thisCfi = cfi?.takeIf { it.isNotBlank() }
|
||||
val otherCfi = other.cfi?.takeIf { it.isNotBlank() }
|
||||
if (thisCfi != null && otherCfi != null) return thisCfi == otherCfi
|
||||
|
||||
val thisElementId = elementId?.takeIf { it.isNotBlank() }
|
||||
val otherElementId = other.elementId?.takeIf { it.isNotBlank() }
|
||||
if (thisElementId != null && otherElementId != null) return thisElementId == otherElementId
|
||||
|
||||
return blockIndex == other.blockIndex && path == other.path
|
||||
}
|
||||
|
||||
private fun String.readerImageExtension(): String? {
|
||||
val dataMime = Regex("""^data:([^;,]+)""", RegexOption.IGNORE_CASE)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.lowercase()
|
||||
val extensionFromMime = when (dataMime) {
|
||||
"image/jpeg" -> "jpg"
|
||||
"image/png" -> "png"
|
||||
"image/gif" -> "gif"
|
||||
"image/webp" -> "webp"
|
||||
"image/bmp" -> "bmp"
|
||||
"image/svg+xml" -> "svg"
|
||||
else -> null
|
||||
}
|
||||
if (extensionFromMime != null) return extensionFromMime
|
||||
return substringBefore('#')
|
||||
.substringBefore('?')
|
||||
.substringAfterLast('.', "")
|
||||
.lowercase()
|
||||
.takeIf { it in setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "svg") }
|
||||
}
|
||||
|
||||
private fun String.sanitizedReaderImageFileBase(): String {
|
||||
return replace(Regex("""[\\/:*?"<>|]+"""), "_")
|
||||
.replace(Regex("""\s+"""), " ")
|
||||
.trim()
|
||||
.trim('.')
|
||||
.take(80)
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ data class ReaderSettings(
|
|||
val pageSpreadMode: ReaderPageSpreadMode = ReaderPageSpreadMode.SINGLE,
|
||||
val pdfVerticalPageGapVisible: Boolean = true,
|
||||
val pdfPageNumberOverlayVisible: Boolean = true,
|
||||
val pdfFirstPageStandaloneInSpread: Boolean = false,
|
||||
val seamlessChapterNavigation: Boolean = true,
|
||||
val chapterTurnDragMultiplier: Float = 1.0f
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ import com.aryan.reader.shared.progressPercentValue
|
|||
import com.aryan.reader.shared.toHomeScreenModel
|
||||
|
||||
enum class SharedAppToolAction {
|
||||
SETTINGS,
|
||||
IMPORT_FILES,
|
||||
IMPORT_FOLDER,
|
||||
SYNC,
|
||||
APP_THEME,
|
||||
PRO,
|
||||
AI_SETTINGS,
|
||||
CUSTOM_FONTS,
|
||||
HELP_FEEDBACK,
|
||||
|
|
@ -39,24 +41,27 @@ fun sharedAppShellModel(
|
|||
featurePolicy: SharedFeaturePolicy = SharedFeaturePolicy.Standard
|
||||
): SharedAppShellModel {
|
||||
val primaryTabs = buildList {
|
||||
add(SharedAppTab.HOME)
|
||||
add(SharedAppTab.LIBRARY)
|
||||
if (featurePolicy.opdsCatalogs) add(SharedAppTab.CATALOGS)
|
||||
}
|
||||
val selectedPrimaryTab = when (selectedTab) {
|
||||
SharedAppTab.HOME -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SHELVES -> SharedAppTab.LIBRARY
|
||||
SharedAppTab.SETTINGS,
|
||||
SharedAppTab.PRO,
|
||||
SharedAppTab.CUSTOM_FONTS,
|
||||
SharedAppTab.SUPPORT,
|
||||
SharedAppTab.FEEDBACK,
|
||||
SharedAppTab.ABOUT -> SharedAppTab.HOME
|
||||
SharedAppTab.ABOUT -> SharedAppTab.LIBRARY
|
||||
else -> selectedTab
|
||||
}.takeIf { it in primaryTabs } ?: SharedAppTab.HOME
|
||||
}.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) {
|
||||
|
|
@ -124,29 +129,73 @@ data class NonReaderLibraryOrganizationModel(
|
|||
)
|
||||
|
||||
internal data class NonReaderLibraryFileTypeGroup(
|
||||
val title: String,
|
||||
val titleKey: String,
|
||||
val titleFallback: String,
|
||||
val fileTypes: List<FileType>
|
||||
)
|
||||
|
||||
private val LibraryFileTypeGroupTemplates = listOf(
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Books",
|
||||
titleKey = "desktop_file_type_group_books",
|
||||
titleFallback = "Books",
|
||||
fileTypes = listOf(FileType.EPUB, FileType.MOBI, FileType.FB2)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Documents",
|
||||
titleKey = "desktop_file_type_group_documents",
|
||||
titleFallback = "Documents",
|
||||
fileTypes = listOf(FileType.PDF, FileType.PPTX, FileType.DOCX, FileType.ODT, FileType.FODT)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Text and web",
|
||||
titleKey = "desktop_file_type_group_text_web",
|
||||
titleFallback = "Text and web",
|
||||
fileTypes = listOf(FileType.MD, FileType.TXT, FileType.HTML)
|
||||
),
|
||||
NonReaderLibraryFileTypeGroup(
|
||||
title = "Comics",
|
||||
titleKey = "desktop_file_type_group_comics",
|
||||
titleFallback = "Comics",
|
||||
fileTypes = listOf(FileType.CBZ, FileType.CBR, FileType.CB7)
|
||||
)
|
||||
)
|
||||
|
||||
private val AndroidLibraryTabs = listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS
|
||||
)
|
||||
|
||||
private val DesktopLibraryTabs = listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS
|
||||
)
|
||||
|
||||
internal fun visibleNonReaderLibraryTabs(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): List<NonReaderLibraryTab> {
|
||||
return when (platform) {
|
||||
ReaderPlatform.ANDROID -> AndroidLibraryTabs
|
||||
ReaderPlatform.DESKTOP -> DesktopLibraryTabs
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NonReaderLibraryTab.visibleLibraryTab(
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): NonReaderLibraryTab {
|
||||
return takeIf { it in visibleNonReaderLibraryTabs(platform) } ?: NonReaderLibraryTab.BOOKS
|
||||
}
|
||||
|
||||
internal fun SharedReaderScreenState.booksForNonReaderLibraryTab(
|
||||
tab: NonReaderLibraryTab,
|
||||
platform: ReaderPlatform = ReaderPlatform.ANDROID
|
||||
): List<BookItem> {
|
||||
return when (tab.visibleLibraryTab(platform)) {
|
||||
NonReaderLibraryTab.UNREAD -> libraryBooks.filter { progressPercentValue(it.progressPercentage) == 0 }
|
||||
NonReaderLibraryTab.IN_PROGRESS -> libraryBooks.filter { progressPercentValue(it.progressPercentage) in 1..99 }
|
||||
NonReaderLibraryTab.COMPLETED -> libraryBooks.filter { progressPercentValue(it.progressPercentage) >= 100 }
|
||||
else -> libraryBooks
|
||||
}
|
||||
}
|
||||
|
||||
internal fun nonReaderLibraryFileTypeGroups(
|
||||
platform: ReaderPlatform = ReaderPlatform.DESKTOP
|
||||
): List<NonReaderLibraryFileTypeGroup> {
|
||||
|
|
@ -163,7 +212,11 @@ internal fun nonReaderLibraryFileTypeGroups(
|
|||
return if (otherTypes.isEmpty()) {
|
||||
grouped
|
||||
} else {
|
||||
grouped + NonReaderLibraryFileTypeGroup("Other", otherTypes)
|
||||
grouped + NonReaderLibraryFileTypeGroup(
|
||||
titleKey = "desktop_file_type_group_other",
|
||||
titleFallback = "Other",
|
||||
fileTypes = otherTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,7 @@ enum class ReaderWorkspaceKind {
|
|||
|
||||
enum class ReaderWorkspaceLeftSection(val title: String) {
|
||||
CONTENTS("Contents"),
|
||||
IMAGES("Images"),
|
||||
SEARCH("Search"),
|
||||
BOOKMARKS("Bookmarks"),
|
||||
NOTES("Annotations"),
|
||||
|
|
@ -33,6 +34,7 @@ enum class ReaderWorkspaceTopAction {
|
|||
CONTENTS,
|
||||
SEARCH,
|
||||
BOOKMARK,
|
||||
FILE_ACTIONS,
|
||||
FULL_SCREEN,
|
||||
APPEARANCE,
|
||||
READ_ALOUD,
|
||||
|
|
@ -53,6 +55,23 @@ data class ReaderWorkspaceChromeModel(
|
|||
val forceVisibleReasons: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
data class ReaderWorkspaceFileActionState(
|
||||
val canShare: Boolean = false,
|
||||
val canSaveCopy: Boolean = false,
|
||||
val canPrint: Boolean = false,
|
||||
val canGenerateTextView: Boolean = false,
|
||||
val hasGeneratedTextView: Boolean = false,
|
||||
val isGeneratingTextView: Boolean = false
|
||||
) {
|
||||
val hasAnyAction: Boolean
|
||||
get() = canShare ||
|
||||
canSaveCopy ||
|
||||
canPrint ||
|
||||
canGenerateTextView ||
|
||||
hasGeneratedTextView ||
|
||||
isGeneratingTextView
|
||||
}
|
||||
|
||||
data class ReaderWorkspacePanelDefaults(
|
||||
val leftOpen: Boolean = false,
|
||||
val inspectorOpen: Boolean = false
|
||||
|
|
@ -81,7 +100,8 @@ fun epubReaderWorkspaceModel(
|
|||
val leftSections = listOf(
|
||||
ReaderWorkspaceLeftSection.CONTENTS,
|
||||
ReaderWorkspaceLeftSection.NOTES,
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS,
|
||||
ReaderWorkspaceLeftSection.IMAGES
|
||||
)
|
||||
val inspectorSections = buildList {
|
||||
if (preferences.isVisible(ReaderTool.THEME) || preferences.isVisible(ReaderTool.FORMAT)) {
|
||||
|
|
@ -121,7 +141,7 @@ fun epubReaderWorkspaceModel(
|
|||
topActions = topActions,
|
||||
bottomActions = bottomActions,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = false,
|
||||
preferAutoHide = true,
|
||||
searchActive = session.isSearchActive,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
|
|
@ -186,6 +206,7 @@ fun pdfReaderWorkspaceModel(
|
|||
add(ReaderWorkspaceTopAction.CONTENTS)
|
||||
add(ReaderWorkspaceTopAction.SEARCH)
|
||||
add(ReaderWorkspaceTopAction.BOOKMARK)
|
||||
add(ReaderWorkspaceTopAction.FILE_ACTIONS)
|
||||
add(ReaderWorkspaceTopAction.FULL_SCREEN)
|
||||
add(ReaderWorkspaceTopAction.APPEARANCE)
|
||||
if (cloudTtsAvailable) add(ReaderWorkspaceTopAction.READ_ALOUD)
|
||||
|
|
@ -205,7 +226,7 @@ fun pdfReaderWorkspaceModel(
|
|||
),
|
||||
defaultPdfInteractionMode = null,
|
||||
chrome = readerWorkspaceChromeModel(
|
||||
preferAutoHide = false,
|
||||
preferAutoHide = true,
|
||||
searchActive = searchActive || state.searchQuery.isNotBlank(),
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.Info
|
|||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.Button
|
||||
|
|
@ -77,6 +78,7 @@ enum class SharedAppTab {
|
|||
CATALOGS,
|
||||
READER,
|
||||
SETTINGS,
|
||||
PRO,
|
||||
CUSTOM_FONTS,
|
||||
SUPPORT,
|
||||
FEEDBACK,
|
||||
|
|
@ -140,14 +142,14 @@ fun SharedAppShell(
|
|||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) }
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
)
|
||||
} else {
|
||||
SharedAppCompactRail(
|
||||
selectedTab = shellModel.selectedPrimaryTab,
|
||||
primaryTabs = shellModel.primaryTabs,
|
||||
onTabSelected = onTabSelected,
|
||||
onToolsClick = { onTabSelected(SharedAppTab.SETTINGS) }
|
||||
onToolsClick = { showToolsPanel = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -242,20 +244,20 @@ private fun SharedAppSidebar(
|
|||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(244.dp)
|
||||
.width(SharedUiTokens.sidebarWidth)
|
||||
.fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(SharedUiTokens.compactGap)
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 12.dp)) {
|
||||
Text("Episteme", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Desktop library", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
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)
|
||||
}
|
||||
primaryTabs.forEach { tab ->
|
||||
SharedSidebarNavItem(
|
||||
|
|
@ -267,7 +269,7 @@ private fun SharedAppSidebar(
|
|||
Spacer(Modifier.weight(1f))
|
||||
HorizontalDivider()
|
||||
SharedSidebarButton(
|
||||
label = "Settings",
|
||||
label = readerString("desktop_tools", "Tools"),
|
||||
icon = Icons.Default.Settings,
|
||||
onClick = onToolsClick
|
||||
)
|
||||
|
|
@ -282,18 +284,18 @@ private fun SharedAppCompactRail(
|
|||
onTabSelected: (SharedAppTab) -> Unit,
|
||||
onToolsClick: () -> Unit
|
||||
) {
|
||||
NavigationRail(containerColor = MaterialTheme.colorScheme.surface) {
|
||||
NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) {
|
||||
primaryTabs.forEach { tab ->
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == tab,
|
||||
onClick = { onTabSelected(tab) },
|
||||
icon = { Icon(tab.icon, contentDescription = null) },
|
||||
label = { Text(tab.label) }
|
||||
label = { Text(tab.localizedLabel()) }
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onToolsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
Icon(Icons.Default.Settings, contentDescription = readerString("desktop_tools", "Tools"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -327,7 +329,7 @@ private fun SharedSidebarNavItem(
|
|||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(tab.icon, contentDescription = null, modifier = Modifier.size(21.dp))
|
||||
Text(tab.label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
|
||||
Text(tab.localizedLabel(), style = MaterialTheme.typography.bodyMedium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -371,10 +373,14 @@ 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.AI_SETTINGS 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 ||
|
||||
|
|
@ -395,29 +401,65 @@ private fun SharedToolsPanel(
|
|||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Tools", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text("Import, sync, and app settings", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
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 = "Close tools")
|
||||
Icon(Icons.Default.Close, contentDescription = readerString("desktop_close_tools", "Close tools"))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
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 (hasLibraryActions) {
|
||||
SharedToolsSection("Library") {
|
||||
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("Import files")
|
||||
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("Add folder")
|
||||
Text(readerString("fab_add_folder", "Add folder"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -426,11 +468,11 @@ private fun SharedToolsPanel(
|
|||
FilledTonalButton(onClick = onSyncRequested, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Sync, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Sync folders")
|
||||
Text(readerString("desktop_sync_folders", "Sync folders"))
|
||||
}
|
||||
} else {
|
||||
SharedToolRow(Icons.Default.Sync, "Sync metadata", onFolderMetadataSyncRequested)
|
||||
SharedToolRow(Icons.Default.Search, "Full scan") {
|
||||
SharedToolRow(Icons.Default.Sync, readerString("desktop_sync_metadata", "Sync metadata"), onFolderMetadataSyncRequested)
|
||||
SharedToolRow(Icons.Default.Search, readerString("desktop_full_scan", "Full scan")) {
|
||||
onSyncRequested()
|
||||
}
|
||||
}
|
||||
|
|
@ -438,54 +480,30 @@ private fun SharedToolsPanel(
|
|||
}
|
||||
}
|
||||
|
||||
SharedToolsSection("Appearance") {
|
||||
if (SharedAppToolAction.APP_THEME in toolActions) {
|
||||
SharedToolRow(
|
||||
icon = Icons.Default.Palette,
|
||||
title = "App theme",
|
||||
onClick = onAppThemeRequested
|
||||
)
|
||||
}
|
||||
if (SharedAppToolAction.TABS_TOGGLE in toolActions) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Active reader tabs", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(if (isTabsEnabled) "Enabled" else "Disabled", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Switch(
|
||||
checked = isTabsEnabled,
|
||||
onCheckedChange = onTabsEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSettingsActions) {
|
||||
SharedToolsSection("Settings") {
|
||||
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, "AI keys and models", onAiSettingsRequested)
|
||||
SharedToolRow(Icons.Default.Settings, readerString("ai_settings_title", "AI keys and models"), onAiSettingsRequested)
|
||||
}
|
||||
if (SharedAppToolAction.CUSTOM_FONTS in toolActions) {
|
||||
SharedToolRow(Icons.Default.TextFields, "Custom fonts") { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
SharedToolRow(Icons.Default.TextFields, readerString("custom_fonts", "Custom fonts")) { onOpenTab(SharedAppTab.CUSTOM_FONTS) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasProjectActions) {
|
||||
SharedToolsSection("Project") {
|
||||
SharedToolsSection(readerString("desktop_project", "Project")) {
|
||||
if (SharedAppToolAction.HELP_FEEDBACK in toolActions) {
|
||||
SharedToolRow(Icons.Default.Feedback, "Help & feedback") { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
SharedToolRow(Icons.Default.Feedback, readerString("drawer_help_feedback", "Help & feedback")) { onOpenTab(SharedAppTab.FEEDBACK) }
|
||||
}
|
||||
if (SharedAppToolAction.SUPPORT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Favorite, "Support project") { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
SharedToolRow(Icons.Default.Favorite, readerString("drawer_support_project", "Support project")) { onOpenTab(SharedAppTab.SUPPORT) }
|
||||
}
|
||||
if (SharedAppToolAction.ABOUT in toolActions) {
|
||||
SharedToolRow(Icons.Default.Info, "About Episteme") { onOpenTab(SharedAppTab.ABOUT) }
|
||||
SharedToolRow(Icons.Default.Info, readerString("about_title", "About Episteme")) { onOpenTab(SharedAppTab.ABOUT) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -529,19 +547,22 @@ private fun SharedToolRow(
|
|||
}
|
||||
}
|
||||
|
||||
private val SharedAppTab.label: String
|
||||
get() = when (this) {
|
||||
SharedAppTab.HOME -> "Home"
|
||||
SharedAppTab.LIBRARY -> "Library"
|
||||
SharedAppTab.SHELVES -> "Shelves"
|
||||
SharedAppTab.CATALOGS -> "OPDS"
|
||||
SharedAppTab.READER -> "Reader"
|
||||
SharedAppTab.SETTINGS -> "Settings"
|
||||
SharedAppTab.CUSTOM_FONTS -> "Custom fonts"
|
||||
SharedAppTab.SUPPORT -> "Support"
|
||||
SharedAppTab.FEEDBACK -> "Feedback"
|
||||
SharedAppTab.ABOUT -> "About"
|
||||
@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.CUSTOM_FONTS -> readerString("custom_fonts", "Custom fonts")
|
||||
SharedAppTab.SUPPORT -> readerString("desktop_support", "Support")
|
||||
SharedAppTab.FEEDBACK -> readerString("desktop_feedback", "Feedback")
|
||||
SharedAppTab.ABOUT -> readerString("desktop_about", "About")
|
||||
}
|
||||
}
|
||||
|
||||
private val SharedAppTab.icon: ImageVector
|
||||
get() = when (this) {
|
||||
|
|
@ -551,6 +572,7 @@ private val SharedAppTab.icon: ImageVector
|
|||
SharedAppTab.CATALOGS -> Icons.Default.Cloud
|
||||
SharedAppTab.READER -> Icons.AutoMirrored.Filled.MenuBook
|
||||
SharedAppTab.SETTINGS -> Icons.Default.Settings
|
||||
SharedAppTab.PRO -> Icons.Default.Star
|
||||
SharedAppTab.CUSTOM_FONTS -> Icons.Default.TextFields
|
||||
SharedAppTab.SUPPORT -> Icons.Default.Favorite
|
||||
SharedAppTab.FEEDBACK -> Icons.Default.Feedback
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import androidx.compose.ui.graphics.toArgb
|
|||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -170,6 +171,7 @@ fun SharedAppTheme(
|
|||
appTextDimFactorLight: Float,
|
||||
appTextDimFactorDark: Float,
|
||||
appSeedColor: Color?,
|
||||
appFontFamily: FontFamily? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = resolveSharedAppDarkTheme(appThemeMode, isSystemInDarkTheme())
|
||||
|
|
@ -185,11 +187,31 @@ fun SharedAppTheme(
|
|||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography(),
|
||||
typography = appFontFamily?.let { Typography().withAppFontFamily(it) } ?: Typography(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
fun Typography.withAppFontFamily(fontFamily: FontFamily): Typography {
|
||||
return copy(
|
||||
displayLarge = displayLarge.copy(fontFamily = fontFamily),
|
||||
displayMedium = displayMedium.copy(fontFamily = fontFamily),
|
||||
displaySmall = displaySmall.copy(fontFamily = fontFamily),
|
||||
headlineLarge = headlineLarge.copy(fontFamily = fontFamily),
|
||||
headlineMedium = headlineMedium.copy(fontFamily = fontFamily),
|
||||
headlineSmall = headlineSmall.copy(fontFamily = fontFamily),
|
||||
titleLarge = titleLarge.copy(fontFamily = fontFamily),
|
||||
titleMedium = titleMedium.copy(fontFamily = fontFamily),
|
||||
titleSmall = titleSmall.copy(fontFamily = fontFamily),
|
||||
bodyLarge = bodyLarge.copy(fontFamily = fontFamily),
|
||||
bodyMedium = bodyMedium.copy(fontFamily = fontFamily),
|
||||
bodySmall = bodySmall.copy(fontFamily = fontFamily),
|
||||
labelLarge = labelLarge.copy(fontFamily = fontFamily),
|
||||
labelMedium = labelMedium.copy(fontFamily = fontFamily),
|
||||
labelSmall = labelSmall.copy(fontFamily = fontFamily)
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveSharedAppDarkTheme(mode: AppThemeMode, isSystemDark: Boolean): Boolean {
|
||||
return when (mode) {
|
||||
AppThemeMode.LIGHT -> false
|
||||
|
|
@ -246,10 +268,11 @@ fun SharedAppThemeSettingsDialog(
|
|||
onDismiss: () -> Unit
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("App theme", fontWeight = FontWeight.Bold) },
|
||||
title = { Text(readerString("app_theme_title", "App theme"), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -258,42 +281,42 @@ fun SharedAppThemeSettingsDialog(
|
|||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
SettingsLabel("Appearance")
|
||||
SettingsLabel(readerString("app_theme_appearance", "Appearance"))
|
||||
SegmentedControl(
|
||||
values = AppThemeMode.entries,
|
||||
selectedValue = appThemeMode,
|
||||
label = { it.label },
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onThemeModeChanged
|
||||
)
|
||||
|
||||
SettingsLabel("Contrast")
|
||||
SettingsLabel(readerString("app_theme_contrast", "Contrast"))
|
||||
SegmentedControl(
|
||||
values = AppContrastOption.entries,
|
||||
selectedValue = appContrastOption,
|
||||
label = { it.label },
|
||||
label = { it.localizedLabel() },
|
||||
onValueSelected = onContrastOptionChanged
|
||||
)
|
||||
|
||||
if (appThemeMode == AppThemeMode.SYSTEM) {
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness (Light)",
|
||||
label = readerString("app_theme_text_brightness_light", "Text brightness (Light)"),
|
||||
value = appTextDimFactorLight,
|
||||
onValueChange = onTextDimFactorLightChanged
|
||||
)
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness (Dark)",
|
||||
label = readerString("app_theme_text_brightness_dark", "Text brightness (Dark)"),
|
||||
value = appTextDimFactorDark,
|
||||
onValueChange = onTextDimFactorDarkChanged
|
||||
)
|
||||
} else {
|
||||
TextBrightnessSlider(
|
||||
label = "Text brightness",
|
||||
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("Color scheme")
|
||||
SettingsLabel(readerString("app_theme_color_scheme", "Color scheme"))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
|
|
@ -301,14 +324,14 @@ fun SharedAppThemeSettingsDialog(
|
|||
ThemeSwatch(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
selected = appSeedColor == null,
|
||||
label = "Dynamic",
|
||||
label = readerString("app_theme_dynamic", "Dynamic"),
|
||||
onClick = { onSeedColorChanged(null) }
|
||||
)
|
||||
AppThemePresets.forEach { preset ->
|
||||
ThemeSwatch(
|
||||
color = preset.color,
|
||||
selected = appSeedColor == preset.color,
|
||||
label = preset.name,
|
||||
label = readerString(preset.nameKey, preset.nameFallback),
|
||||
onClick = { onSeedColorChanged(preset.color) }
|
||||
)
|
||||
}
|
||||
|
|
@ -321,15 +344,15 @@ fun SharedAppThemeSettingsDialog(
|
|||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SettingsLabel("My themes")
|
||||
SettingsLabel(readerString("theme_my_themes", "My themes"))
|
||||
IconButton(onClick = { showCreateDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add custom theme")
|
||||
Icon(Icons.Default.Add, contentDescription = readerString("content_desc_add_custom_theme", "Add custom theme"))
|
||||
}
|
||||
}
|
||||
|
||||
if (customAppThemes.isEmpty()) {
|
||||
Text(
|
||||
"No custom themes yet",
|
||||
readerString("desktop_no_custom_themes_yet", "No custom themes yet"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -353,7 +376,7 @@ fun SharedAppThemeSettingsDialog(
|
|||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Done")
|
||||
Text(readerString("action_done", "Done"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -365,7 +388,7 @@ fun SharedAppThemeSettingsDialog(
|
|||
onCustomThemeAdded(
|
||||
CustomAppTheme(
|
||||
id = Random.nextLong().toString(),
|
||||
name = name.ifBlank { "Custom" },
|
||||
name = name.ifBlank { defaultCustomThemeName },
|
||||
seedColor = color
|
||||
)
|
||||
)
|
||||
|
|
@ -389,7 +412,7 @@ private fun SettingsLabel(label: String) {
|
|||
private fun <T> SegmentedControl(
|
||||
values: List<T>,
|
||||
selectedValue: T,
|
||||
label: (T) -> String,
|
||||
label: @Composable (T) -> String,
|
||||
onValueSelected: (T) -> Unit
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -401,6 +424,7 @@ private fun <T> SegmentedControl(
|
|||
) {
|
||||
values.forEach { value ->
|
||||
val selected = selectedValue == value
|
||||
val valueLabel = label(value)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
|
|
@ -411,7 +435,7 @@ private fun <T> SegmentedControl(
|
|||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = label(value),
|
||||
text = valueLabel,
|
||||
color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
|
@ -501,7 +525,7 @@ private fun ThemeSwatch(
|
|||
if (onDelete != null) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Delete",
|
||||
contentDescription = readerString("action_delete", "Delete"),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp).clickable(onClick = onDelete)
|
||||
)
|
||||
|
|
@ -519,6 +543,7 @@ private fun SharedCreateAppThemeDialog(
|
|||
var name by remember { mutableStateOf("") }
|
||||
var hsv by remember(initialColor) { mutableStateOf(initialColor.toSharedHsvColor()) }
|
||||
val color = hsv.toComposeColor()
|
||||
val defaultCustomThemeName = readerString("desktop_custom_theme_default", "Custom")
|
||||
|
||||
fun updateFromColor(nextColor: Color) {
|
||||
hsv = nextColor.toSharedHsvColor()
|
||||
|
|
@ -526,7 +551,7 @@ private fun SharedCreateAppThemeDialog(
|
|||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Create theme") },
|
||||
title = { Text(readerString("desktop_create_theme", "Create theme")) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 560.dp).verticalScroll(rememberScrollState()),
|
||||
|
|
@ -536,7 +561,7 @@ private fun SharedCreateAppThemeDialog(
|
|||
SharedStableOutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Theme name") },
|
||||
label = { Text(readerString("theme_name", "Theme name")) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
|
@ -574,7 +599,7 @@ private fun SharedCreateAppThemeDialog(
|
|||
modifier = Modifier.weight(1.6f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
|
||||
Text(readerString("theme_color_hex", "Hex"), color = Color.Gray, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SharedHexInput(color = color, onHexChanged = { updateFromColor(it) })
|
||||
}
|
||||
|
|
@ -607,18 +632,18 @@ private fun SharedCreateAppThemeDialog(
|
|||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onSave(name.trim().ifBlank { "Custom" }, color) },
|
||||
onClick = { onSave(name.trim().ifBlank { defaultCustomThemeName }, color) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = color,
|
||||
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
) {
|
||||
Text("Save", fontWeight = FontWeight.Bold)
|
||||
Text(readerString("action_save", "Save"), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -664,7 +689,7 @@ fun SharedHsvColorPickerDialog(
|
|||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
Icon(Icons.Default.Close, contentDescription = readerString("action_close", "Close"))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
|
|
@ -710,7 +735,7 @@ fun SharedHsvColorPickerDialog(
|
|||
modifier = Modifier.weight(1.6f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
|
||||
Text(readerString("theme_color_hex", "Hex"), color = Color.Gray, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SharedHexInput(color = color, onHexChanged = { updateFromColor(it) })
|
||||
}
|
||||
|
|
@ -746,7 +771,7 @@ fun SharedHsvColorPickerDialog(
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
Button(
|
||||
onClick = { onSave(color) },
|
||||
|
|
@ -755,7 +780,7 @@ fun SharedHsvColorPickerDialog(
|
|||
contentColor = if (color.luminance() > 0.5f) Color.Black else Color.White
|
||||
)
|
||||
) {
|
||||
Text("Save", fontWeight = FontWeight.Bold)
|
||||
Text(readerString("action_save", "Save"), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1161,33 +1186,38 @@ private fun ColorScheme.withTextDimFactor(factor: Float): ColorScheme {
|
|||
}
|
||||
|
||||
private data class AppThemePreset(
|
||||
val name: String,
|
||||
val nameKey: String,
|
||||
val nameFallback: String,
|
||||
val color: Color
|
||||
)
|
||||
|
||||
private val AppThemePresets = listOf(
|
||||
AppThemePreset("Ocean", Color(0xFF00668B)),
|
||||
AppThemePreset("Mint", Color(0xFF006C4C)),
|
||||
AppThemePreset("Rose", Color(0xFF9C4146)),
|
||||
AppThemePreset("Sepia", Color(0xFF705D49)),
|
||||
AppThemePreset("Amethyst", Color(0xFF9B59B6)),
|
||||
AppThemePreset("Amber", Color(0xFFFFC107)),
|
||||
AppThemePreset("Sapphire", Color(0xFF0F52BA))
|
||||
AppThemePreset("desktop_theme_preset_ocean", "Ocean", Color(0xFF00668B)),
|
||||
AppThemePreset("desktop_theme_preset_mint", "Mint", Color(0xFF006C4C)),
|
||||
AppThemePreset("desktop_theme_preset_rose", "Rose", Color(0xFF9C4146)),
|
||||
AppThemePreset("desktop_theme_preset_sepia", "Sepia", Color(0xFF705D49)),
|
||||
AppThemePreset("desktop_theme_preset_amethyst", "Amethyst", Color(0xFF9B59B6)),
|
||||
AppThemePreset("desktop_theme_preset_amber", "Amber", Color(0xFFFFC107)),
|
||||
AppThemePreset("desktop_theme_preset_sapphire", "Sapphire", Color(0xFF0F52BA))
|
||||
)
|
||||
|
||||
private val AppThemeMode.label: String
|
||||
get() = when (this) {
|
||||
AppThemeMode.SYSTEM -> "System"
|
||||
AppThemeMode.LIGHT -> "Light"
|
||||
AppThemeMode.DARK -> "Dark"
|
||||
@Composable
|
||||
private fun AppThemeMode.localizedLabel(): String {
|
||||
return when (this) {
|
||||
AppThemeMode.SYSTEM -> readerString("language_system_default", "System")
|
||||
AppThemeMode.LIGHT -> readerString("app_theme_mode_light", "Light")
|
||||
AppThemeMode.DARK -> readerString("app_theme_mode_dark", "Dark")
|
||||
}
|
||||
}
|
||||
|
||||
private val AppContrastOption.label: String
|
||||
get() = when (this) {
|
||||
AppContrastOption.STANDARD -> "Standard"
|
||||
AppContrastOption.MEDIUM -> "Medium"
|
||||
AppContrastOption.HIGH -> "High"
|
||||
@Composable
|
||||
private fun AppContrastOption.localizedLabel(): String {
|
||||
return when (this) {
|
||||
AppContrastOption.STANDARD -> readerString("app_contrast_standard", "Standard")
|
||||
AppContrastOption.MEDIUM -> readerString("app_contrast_medium", "Medium")
|
||||
AppContrastOption.HIGH -> readerString("app_contrast_high", "High")
|
||||
}
|
||||
}
|
||||
|
||||
internal data class SharedHsvColor(
|
||||
val hue: Float,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ fun SharedTextInputDialog(
|
|||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -120,7 +120,7 @@ fun SharedConfirmDialog(
|
|||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -135,10 +135,10 @@ fun SharedAddToShelfDialog(
|
|||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add to shelf") },
|
||||
title = { Text(readerString("desktop_add_to_shelf", "Add to shelf")) },
|
||||
text = {
|
||||
if (shelves.isEmpty()) {
|
||||
Text("Create a shelf first, then add selected books to it.")
|
||||
Text(readerString("desktop_create_shelf_first", "Create a shelf first, then add selected books to it."))
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(shelves, key = { it.id }) { shelf ->
|
||||
|
|
@ -165,12 +165,12 @@ fun SharedAddToShelfDialog(
|
|||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onCreateShelf) {
|
||||
Text("New shelf")
|
||||
Text(readerString("fab_new_shelf", "New shelf"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -221,9 +221,13 @@ fun SharedBookInfoDialog(
|
|||
Column(Modifier.fillMaxSize()) {
|
||||
SharedBookInfoTopBar(
|
||||
title = if (isEditing) {
|
||||
if (canEditEmbeddedMetadata) "Edit EPUB metadata" else "Rename in app"
|
||||
if (canEditEmbeddedMetadata) {
|
||||
readerString("desktop_edit_epub_metadata", "Edit EPUB metadata")
|
||||
} else {
|
||||
readerString("desktop_rename_in_app", "Rename in app")
|
||||
}
|
||||
} else {
|
||||
"Book information"
|
||||
readerString("file_information", "Book information")
|
||||
},
|
||||
subtitle = book.cardTitle(),
|
||||
onClose = {
|
||||
|
|
@ -287,7 +291,7 @@ fun SharedBookInfoDialog(
|
|||
isEditing = isEditing,
|
||||
canEdit = canEditEmbeddedMetadata || canRenameDisplayName,
|
||||
canRestore = canRestoreEmbeddedMetadata && hasOriginalMetadata && (hasMetadataChanges || isEditing),
|
||||
editLabel = if (canEditEmbeddedMetadata) "Edit metadata" else "Rename",
|
||||
editLabel = if (canEditEmbeddedMetadata) readerString("action_edit", "Edit") else readerString("action_rename", "Rename"),
|
||||
onCancel = {
|
||||
if (isEditing) {
|
||||
isEditing = false
|
||||
|
|
@ -331,10 +335,13 @@ fun SharedBookInfoDialog(
|
|||
AlertDialog(
|
||||
onDismissRequest = { showRestoreConfirmation = false },
|
||||
icon = { Icon(Icons.Default.Restore, contentDescription = null) },
|
||||
title = { Text("Restore original metadata?") },
|
||||
title = { Text(readerString("dialog_restore_original_metadata", "Restore original metadata?")) },
|
||||
text = {
|
||||
Text(
|
||||
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
|
||||
readerString(
|
||||
"dialog_restore_original_metadata_desc",
|
||||
"This will write the original title, author, series, and summary back into the EPUB file. Reading progress, tags, and notes will not change."
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
|
@ -345,12 +352,12 @@ fun SharedBookInfoDialog(
|
|||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text("Restore")
|
||||
Text(readerString("action_restore", "Restore"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRestoreConfirmation = false }) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -370,7 +377,7 @@ private fun SharedBookInfoTopBar(
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
Icon(Icons.Default.Close, contentDescription = readerString("action_close", "Close"))
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -415,10 +422,10 @@ private fun SharedBookMetadataInfoContent(
|
|||
)
|
||||
}
|
||||
val provenance = when {
|
||||
book.type == FileType.EPUB && hasMetadataChanges -> "EPUB metadata edited"
|
||||
book.type == FileType.EPUB -> "Metadata from EPUB file"
|
||||
hasMetadataChanges -> "Display name changed in app"
|
||||
else -> "Metadata from file"
|
||||
book.type == FileType.EPUB && hasMetadataChanges -> readerString("metadata_provenance_epub_edited", "EPUB metadata edited")
|
||||
book.type == FileType.EPUB -> readerString("metadata_provenance_from_epub", "Metadata from EPUB file")
|
||||
hasMetadataChanges -> readerString("metadata_provenance_display_name_changed", "Display name changed in app")
|
||||
else -> readerString("metadata_provenance_from_file", "Metadata from file")
|
||||
}
|
||||
Text(
|
||||
provenance,
|
||||
|
|
@ -427,37 +434,37 @@ private fun SharedBookMetadataInfoContent(
|
|||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Metadata") {
|
||||
SharedInfoRowDetailed("Title", book.title?.takeIf { it.isNotBlank() } ?: book.displayName, maxLines = 3)
|
||||
SharedInfoSection(title = readerString("section_metadata", "Metadata")) {
|
||||
SharedInfoRowDetailed(readerString("label_title", "Title"), book.title?.takeIf { it.isNotBlank() } ?: book.displayName, maxLines = 3)
|
||||
book.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
||||
SharedInfoRowDetailed("Author", it, maxLines = 2)
|
||||
SharedInfoRowDetailed(readerString("author", "Author"), it, maxLines = 2)
|
||||
}
|
||||
book.seriesLabel()?.let {
|
||||
SharedInfoRowDetailed("Series", it, maxLines = 2)
|
||||
SharedInfoRowDetailed(readerString("label_series", "Series"), it, maxLines = 2)
|
||||
}
|
||||
SharedInfoRowDetailed("Format", book.type.name)
|
||||
SharedInfoRowDetailed("Size", formatFileSize(book.fileSize))
|
||||
SharedInfoRowDetailed("Reading", book.readingProgressText(), maxLines = 2)
|
||||
SharedInfoRowDetailed(readerString("format", "Format"), book.type.name)
|
||||
SharedInfoRowDetailed(readerString("size", "Size"), formatFileSize(book.fileSize))
|
||||
SharedInfoRowDetailed(readerString("label_reading", "Reading"), book.readingProgressText(), maxLines = 2)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "File") {
|
||||
SharedInfoRowDetailed("File name", book.displayName, maxLines = 2)
|
||||
SharedInfoRowDetailed("Location", book.path.orEmpty().ifBlank { "Not available" }, maxLines = 4, onCopy = onCopyPath)
|
||||
SharedInfoSection(title = readerString("section_file", "File")) {
|
||||
SharedInfoRowDetailed(readerString("label_file_name_simple", "File name"), book.displayName, maxLines = 2)
|
||||
SharedInfoRowDetailed(readerString("location", "Location"), book.path.orEmpty().ifBlank { readerString("not_available_locally", "Not available") }, maxLines = 4, onCopy = onCopyPath)
|
||||
book.sourceFolder?.takeIf { it.isNotBlank() }?.let {
|
||||
SharedInfoRowDetailed("Source folder", it, maxLines = 3)
|
||||
SharedInfoRowDetailed(readerString("filter_source_folder", "Source folder"), it, maxLines = 3)
|
||||
}
|
||||
}
|
||||
|
||||
book.description?.takeIf { it.isNotBlank() }?.let { summary ->
|
||||
SharedInfoSection(title = "Summary") {
|
||||
SharedInfoSection(title = readerString("label_summary", "Summary")) {
|
||||
SharedExpandableSummaryText(summary, collapsedMaxLines = 4)
|
||||
}
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Tags") {
|
||||
SharedInfoSection(title = readerString("section_tags", "Tags")) {
|
||||
if (book.tags.isEmpty()) {
|
||||
Text(
|
||||
"No tags assigned",
|
||||
readerString("msg_no_tags_assigned", "No tags assigned."),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
|
@ -490,11 +497,11 @@ private fun SharedBookMetadataEditContent(
|
|||
onTagChange: (String) -> Unit,
|
||||
knownTags: List<Tag>
|
||||
) {
|
||||
SharedInfoSection(title = "Editable metadata") {
|
||||
SharedInfoSection(title = readerString("label_editable_metadata", "Editable metadata")) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = titleInput,
|
||||
onValueChange = onTitleChange,
|
||||
label = { Text("Title") },
|
||||
label = { Text(readerString("label_title", "Title")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "title"
|
||||
|
|
@ -502,7 +509,7 @@ private fun SharedBookMetadataEditContent(
|
|||
SharedStableOutlinedTextField(
|
||||
value = authorInput,
|
||||
onValueChange = onAuthorChange,
|
||||
label = { Text("Author") },
|
||||
label = { Text(readerString("author", "Author")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 2,
|
||||
selectionKey = "author"
|
||||
|
|
@ -511,7 +518,7 @@ private fun SharedBookMetadataEditContent(
|
|||
SharedStableOutlinedTextField(
|
||||
value = seriesInput,
|
||||
onValueChange = onSeriesChange,
|
||||
label = { Text("Series") },
|
||||
label = { Text(readerString("label_series", "Series")) },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2,
|
||||
selectionKey = "series"
|
||||
|
|
@ -529,7 +536,7 @@ private fun SharedBookMetadataEditContent(
|
|||
SharedStableOutlinedTextField(
|
||||
value = descriptionInput,
|
||||
onValueChange = onDescriptionChange,
|
||||
label = { Text("Summary") },
|
||||
label = { Text(readerString("label_summary", "Summary")) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp),
|
||||
|
|
@ -539,18 +546,18 @@ private fun SharedBookMetadataEditContent(
|
|||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Library tags") {
|
||||
SharedInfoSection(title = readerString("label_library_tags", "Library tags")) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = tagInput,
|
||||
onValueChange = onTagChange,
|
||||
label = { Text("Tags, comma separated") },
|
||||
label = { Text(readerString("desktop_tags_comma_separated", "Tags, comma separated")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "tags"
|
||||
)
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text(
|
||||
"Existing: ${knownTags.joinToString { it.name }}",
|
||||
readerString("desktop_existing_tags_format", "Existing: %1\$s", knownTags.joinToString { it.name }),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
|
|
@ -568,29 +575,29 @@ private fun SharedBookDisplayNameEditContent(
|
|||
onTagChange: (String) -> Unit,
|
||||
knownTags: List<Tag>
|
||||
) {
|
||||
SharedInfoSection(title = "Display name") {
|
||||
SharedInfoSection(title = readerString("label_display_name", "Display name")) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = displayNameInput,
|
||||
onValueChange = onDisplayNameChange,
|
||||
label = { Text("Name shown in Reader") },
|
||||
label = { Text(readerString("label_name_shown_in_reader", "Name shown in Reader")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "displayName"
|
||||
)
|
||||
}
|
||||
|
||||
SharedInfoSection(title = "Library tags") {
|
||||
SharedInfoSection(title = readerString("label_library_tags", "Library tags")) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = tagInput,
|
||||
onValueChange = onTagChange,
|
||||
label = { Text("Tags, comma separated") },
|
||||
label = { Text(readerString("desktop_tags_comma_separated", "Tags, comma separated")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
selectionKey = "renameTags"
|
||||
)
|
||||
if (knownTags.isNotEmpty()) {
|
||||
Text(
|
||||
"Existing: ${knownTags.joinToString { it.name }}",
|
||||
readerString("desktop_existing_tags_format", "Existing: %1\$s", knownTags.joinToString { it.name }),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
|
|
@ -626,18 +633,18 @@ private fun SharedBookInfoBottomBar(
|
|||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Restore")
|
||||
Text(readerString("action_restore", "Restore"))
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(if (isEditing) "Cancel" else "Close")
|
||||
Text(if (isEditing) readerString("action_cancel", "Cancel") else readerString("action_close", "Close"))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (isEditing) {
|
||||
Button(onClick = onSave) {
|
||||
Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
Text(readerString("action_save", "Save"))
|
||||
}
|
||||
} else if (canEdit) {
|
||||
Button(onClick = onEdit) {
|
||||
|
|
@ -697,13 +704,13 @@ private fun SharedInfoRowDetailed(
|
|||
Column(modifier = Modifier.weight(1f)) {
|
||||
SharedExpandableValueText(value, collapsedMaxLines = maxLines)
|
||||
}
|
||||
if (onCopy != null && value != "Not available") {
|
||||
if (onCopy != null && value != readerString("not_available_locally", "Not available")) {
|
||||
TextButton(
|
||||
onClick = onCopy,
|
||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
|
||||
modifier = Modifier.height(30.dp)
|
||||
) {
|
||||
Text("Copy")
|
||||
Text(readerString("action_copy", "Copy"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -771,7 +778,7 @@ private fun SharedMoreButton(
|
|||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) {
|
||||
Text(if (expanded) "Less" else "...more")
|
||||
Text(if (expanded) readerString("desktop_less", "Less") else readerString("desktop_more", "...more"))
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ fun SharedNativePaginatedReader(
|
|||
|
||||
if (visiblePages.isEmpty()) {
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Text("No page content", color = renderPlan.foreground.copy(alpha = 0.68f))
|
||||
Text(readerString("desktop_no_page_content", "No page content"), color = renderPlan.foreground.copy(alpha = 0.68f))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ fun SharedOpdsScreen(
|
|||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = onClearError) {
|
||||
Text("Dismiss")
|
||||
Text(readerString("action_dismiss", "Dismiss"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -176,8 +176,8 @@ fun SharedOpdsScreen(
|
|||
catalogToDelete?.let { catalog ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { catalogToDelete = null },
|
||||
title = { Text("Delete catalog") },
|
||||
text = { Text("Delete \"${catalog.title}\"? Streamed books from this catalog may stop opening if credentials change later.") },
|
||||
title = { Text(readerString("delete_catalog", "Delete catalog")) },
|
||||
text = { Text(readerString("desktop_opds_delete_catalog_desc", "Delete \"%1\$s\"? Streamed books from this catalog may stop opening if credentials change later.", catalog.title)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
|
|
@ -185,12 +185,12 @@ fun SharedOpdsScreen(
|
|||
catalogToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
Text(readerString("action_delete", "Delete"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { catalogToDelete = null }) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -232,12 +232,12 @@ private fun SharedOpdsCatalogList(
|
|||
Column(Modifier.fillMaxSize()) {
|
||||
SharedScreenScaffold(
|
||||
title = "OPDS",
|
||||
subtitle = "Browse catalogs, streams, and downloads",
|
||||
subtitle = readerString("desktop_opds_subtitle", "Browse catalogs, streams, and downloads"),
|
||||
trailing = {
|
||||
Button(onClick = onAddCatalog) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Catalog")
|
||||
Text(readerString("desktop_opds_catalog", "Catalog"))
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
|
@ -299,13 +299,13 @@ private fun SharedOpdsFeedView(
|
|||
onNavigateBack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = readerString("action_back", "Back"))
|
||||
}
|
||||
if (showSearch) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search catalog") },
|
||||
placeholder = { Text(readerString("search_catalog_placeholder", "Search catalog")) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
trailingIcon = {
|
||||
|
|
@ -316,14 +316,14 @@ private fun SharedOpdsFeedView(
|
|||
showSearch = false
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
Icon(Icons.Default.Search, contentDescription = readerString("action_search", "Search"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = state.currentFeed?.title ?: "Loading",
|
||||
text = state.currentFeed?.title ?: readerString("status_loading", "Loading"),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
|
|
@ -341,7 +341,7 @@ private fun SharedOpdsFeedView(
|
|||
}
|
||||
if (state.searchUrlTemplate != null) {
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
Icon(Icons.Default.Search, contentDescription = readerString("action_search", "Search"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -375,7 +375,7 @@ private fun SharedOpdsFeedView(
|
|||
val entries = state.currentFeed?.entries.orEmpty()
|
||||
if (entries.isEmpty() && !state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("This feed is empty.")
|
||||
Text(readerString("feed_empty", "This feed is empty."))
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
|
|
@ -463,7 +463,7 @@ private fun SharedOpdsCatalogCard(
|
|||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text(
|
||||
"Preset",
|
||||
readerString("preset_label", "Preset"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
|
|
@ -474,10 +474,10 @@ private fun SharedOpdsCatalogCard(
|
|||
Spacer(Modifier.weight(1f))
|
||||
if (!catalog.isDefault) {
|
||||
IconButton(onClick = onEditCatalog) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit")
|
||||
Icon(Icons.Default.Edit, contentDescription = readerString("action_edit", "Edit"))
|
||||
}
|
||||
IconButton(onClick = onDeleteCatalog) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete")
|
||||
Icon(Icons.Default.Delete, contentDescription = readerString("action_delete", "Delete"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -496,12 +496,12 @@ private fun SharedOpdsEmptyState(onAddCatalog: () -> Unit, modifier: Modifier =
|
|||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text("No catalogs", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Add an OPDS catalog to browse remote books.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(readerString("desktop_opds_no_catalogs", "No catalogs"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("desktop_opds_no_catalogs_desc", "Add an OPDS catalog to browse remote books."), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Button(onClick = onAddCatalog) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add catalog")
|
||||
Text(readerString("fab_add_catalog", "Add catalog"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -520,7 +520,16 @@ private fun SharedOpdsFacetMenu(
|
|||
FilterChip(
|
||||
selected = activeFacet?.isActive == true,
|
||||
onClick = { expanded = true },
|
||||
label = { Text("$groupName: ${activeFacet?.title ?: "Select"}") },
|
||||
label = {
|
||||
Text(
|
||||
readerString(
|
||||
"filter_facet",
|
||||
"%1\$s: %2\$s",
|
||||
groupName,
|
||||
activeFacet?.title ?: readerString("action_select", "Select")
|
||||
)
|
||||
)
|
||||
},
|
||||
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) }
|
||||
)
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
|
|
@ -610,7 +619,7 @@ private fun SharedOpdsBookCard(
|
|||
OutlinedButton(onClick = { onReadBook(localLibraryBook) }, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Read")
|
||||
Text(readerString("action_read", "Read"))
|
||||
}
|
||||
}
|
||||
isDownloading -> SharedOpdsDownloadProgress(downloadState)
|
||||
|
|
@ -619,7 +628,7 @@ private fun SharedOpdsBookCard(
|
|||
FilledTonalButton(onClick = onStreamBook, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Stream")
|
||||
Text(readerString("action_stream", "Stream"))
|
||||
}
|
||||
}
|
||||
Box {
|
||||
|
|
@ -640,7 +649,13 @@ private fun SharedOpdsBookCard(
|
|||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(if (uniqueAcquisitions.isEmpty()) "Unavailable" else "Download")
|
||||
Text(
|
||||
if (uniqueAcquisitions.isEmpty()) {
|
||||
readerString("action_unavailable", "Unavailable")
|
||||
} else {
|
||||
readerString("action_download", "Download")
|
||||
}
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = showFormatMenu, onDismissRequest = { showFormatMenu = false }) {
|
||||
uniqueAcquisitions.forEach { acquisition ->
|
||||
|
|
@ -666,7 +681,7 @@ private fun SharedOpdsDownloadProgress(downloadState: SharedOpdsDownloadState?)
|
|||
val progress = downloadState?.progress
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Downloading", style = MaterialTheme.typography.labelMedium)
|
||||
Text(readerString("status_downloading", "Downloading"), style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (progress != null) {
|
||||
Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
|
||||
|
|
@ -725,9 +740,15 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
)
|
||||
}
|
||||
val secondary = listOfNotNull(
|
||||
entry.publisher?.takeIf { it.isNotBlank() }?.let { "Publisher: $it" },
|
||||
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let { "Published: $it" },
|
||||
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let { "Language: $it" }
|
||||
entry.publisher?.takeIf { it.isNotBlank() }?.let {
|
||||
readerString("filter_facet", "%1\$s: %2\$s", readerString("publisher", "Publisher"), it)
|
||||
},
|
||||
entry.published?.takeIf { it.isNotBlank() }?.substringBefore("T")?.let {
|
||||
readerString("filter_facet", "%1\$s: %2\$s", readerString("published", "Published"), it)
|
||||
},
|
||||
entry.language?.takeIf { it.isNotBlank() }?.uppercase()?.let {
|
||||
readerString("filter_facet", "%1\$s: %2\$s", readerString("language", "Language"), it)
|
||||
}
|
||||
)
|
||||
secondary.forEach { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
|
|
@ -736,7 +757,7 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
Button(onClick = { onReadBook(book) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Check, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Read")
|
||||
Text(readerString("action_read", "Read"))
|
||||
}
|
||||
}
|
||||
if (downloadState?.isDownloading == true) {
|
||||
|
|
@ -746,11 +767,11 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
Button(onClick = onStreamBook, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Default.Cloud, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Stream now")
|
||||
Text(readerString("action_stream_now", "Stream now"))
|
||||
}
|
||||
}
|
||||
if (uniqueAcquisitions.isNotEmpty()) {
|
||||
Text("Download format", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(readerString("download_format", "Download format"), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
uniqueAcquisitions.take(4).forEach { acquisition ->
|
||||
FilledTonalButton(onClick = { onDownloadBook(acquisition) }) {
|
||||
|
|
@ -762,7 +783,7 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
}
|
||||
if (entry.authors.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Authors", style = MaterialTheme.typography.labelLarge)
|
||||
Text(readerString("desktop_authors", "Authors"), style = MaterialTheme.typography.labelLarge)
|
||||
entry.authors.forEach { author ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
|
|
@ -775,7 +796,7 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
}
|
||||
}
|
||||
if (entry.categories.isNotEmpty()) {
|
||||
Text("Categories", style = MaterialTheme.typography.labelLarge)
|
||||
Text(readerString("desktop_categories", "Categories"), style = MaterialTheme.typography.labelLarge)
|
||||
entry.categories.distinct().take(8).forEach { category ->
|
||||
TextButton(onClick = { onSearch(category) }) {
|
||||
Text(category)
|
||||
|
|
@ -784,14 +805,14 @@ private fun SharedOpdsEntryDetailsDialog(
|
|||
}
|
||||
val summary = SharedOpdsText.cleanSummary(entry.summary)
|
||||
if (summary.isNotBlank()) {
|
||||
Text("Synopsis", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("synopsis", "Synopsis"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
Text(summary, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
Text(readerString("action_close", "Close"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -811,17 +832,17 @@ private fun SharedOpdsCatalogDialog(
|
|||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (isEditMode) "Edit catalog" else "Add OPDS catalog") },
|
||||
title = { Text(if (isEditMode) readerString("edit_catalog", "Edit catalog") else readerString("add_opds_catalog", "Add OPDS catalog")) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SharedStableOutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Catalog name") }, singleLine = true, selectionKey = catalog?.id ?: "new:title")
|
||||
SharedStableOutlinedTextField(value = url, onValueChange = { url = it }, label = { Text("URL") }, singleLine = true, selectionKey = catalog?.id ?: "new:url")
|
||||
Text("Authentication optional", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
SharedStableOutlinedTextField(value = username, onValueChange = { username = it }, label = { Text("Username") }, singleLine = true, selectionKey = catalog?.id ?: "new:username")
|
||||
SharedStableOutlinedTextField(value = title, onValueChange = { title = it }, label = { Text(readerString("catalog_name", "Catalog name")) }, singleLine = true, selectionKey = catalog?.id ?: "new:title")
|
||||
SharedStableOutlinedTextField(value = url, onValueChange = { url = it }, label = { Text(readerString("url", "URL")) }, singleLine = true, selectionKey = catalog?.id ?: "new:url")
|
||||
Text(readerString("auth_optional", "Authentication optional"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
SharedStableOutlinedTextField(value = username, onValueChange = { username = it }, label = { Text(readerString("username", "Username")) }, singleLine = true, selectionKey = catalog?.id ?: "new:username")
|
||||
SharedStableOutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
label = { Text(readerString("password", "Password")) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
selectionKey = catalog?.id ?: "new:password"
|
||||
|
|
@ -833,12 +854,12 @@ private fun SharedOpdsCatalogDialog(
|
|||
onClick = { onSave(title, url, username, password) },
|
||||
enabled = title.isNotBlank() && url.isNotBlank()
|
||||
) {
|
||||
Text("Save")
|
||||
Text(readerString("action_save", "Save"))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ fun SharedPdfAnnotationToolDock(
|
|||
DockCircleButton(onClick = onUndo) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Undo,
|
||||
contentDescription = "Undo annotation",
|
||||
contentDescription = readerString("desktop_undo_annotation", "Undo annotation"),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
|
|
@ -248,7 +248,7 @@ fun SharedPdfAnnotationToolDock(
|
|||
DockCircleButton(onClick = onClearPage) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Clear page annotations",
|
||||
contentDescription = readerString("desktop_clear_page_annotations", "Clear page annotations"),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
|
|
@ -543,7 +543,7 @@ fun SharedPdfHighlighterPaletteEditor(
|
|||
val initialColor = Color(sanitized.colors.getOrElse(slot) { SharedPdfHighlighterPalette.defaultColors.first() }).copy(alpha = 1f)
|
||||
SharedHsvColorPickerDialog(
|
||||
initialColor = initialColor,
|
||||
title = "Highlight color ${slot + 1}",
|
||||
title = readerString("desktop_highlight_color_format", "Highlight color %1\$d", slot + 1),
|
||||
onDismiss = { editingSlot = null },
|
||||
onSave = { color ->
|
||||
onPaletteChange(
|
||||
|
|
@ -575,9 +575,9 @@ fun SharedPdfHighlighterPaletteEditor(
|
|||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("PDF highlighter", fontWeight = FontWeight.SemiBold)
|
||||
Text(readerString("desktop_pdf_highlighter", "PDF highlighter"), fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Saved with reader highlight transparency.",
|
||||
readerString("desktop_pdf_highlighter_alpha_desc", "Saved with reader highlight transparency."),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
|
@ -860,7 +860,7 @@ fun SharedPdfTextStyleControls(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Font", color = labelColor, style = MaterialTheme.typography.labelMedium)
|
||||
Text(readerString("select_font", "Font"), color = labelColor, style = MaterialTheme.typography.labelMedium)
|
||||
Box {
|
||||
TextButton(onClick = { fontMenuExpanded = true }) {
|
||||
Text(
|
||||
|
|
@ -944,7 +944,7 @@ fun SharedPdfTextStyleControls(
|
|||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Text", color = labelColor, style = MaterialTheme.typography.labelMedium)
|
||||
Text(readerString("content_desc_text", "Text"), color = labelColor, style = MaterialTheme.typography.labelMedium)
|
||||
SharedTextColorSwatches(
|
||||
palette = SharedPdfTextAnnotationDefaults.textColorPalette,
|
||||
selectedArgb = style.colorArgb,
|
||||
|
|
@ -955,7 +955,7 @@ fun SharedPdfTextStyleControls(
|
|||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Fill", color = labelColor, style = MaterialTheme.typography.labelMedium)
|
||||
Text(readerString("desktop_fill", "Fill"), color = labelColor, style = MaterialTheme.typography.labelMedium)
|
||||
SharedTextColorSwatches(
|
||||
palette = SharedPdfTextAnnotationDefaults.backgroundColorPalette,
|
||||
selectedArgb = style.backgroundColorArgb,
|
||||
|
|
@ -1154,13 +1154,13 @@ private fun SharedPdfToolButton(
|
|||
when (tool) {
|
||||
PdfInkTool.TEXT -> Icon(
|
||||
imageVector = Icons.Default.TextFields,
|
||||
contentDescription = "text",
|
||||
contentDescription = readerString("content_desc_text", "text"),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
PdfInkTool.ERASER -> Icon(
|
||||
imageVector = Icons.Default.Remove,
|
||||
contentDescription = "eraser",
|
||||
contentDescription = readerString("content_desc_eraser", "eraser"),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,11 @@ internal val LocalSharedReaderModalAnchorBounds = compositionLocalOf<SharedReade
|
|||
|
||||
internal enum class SharedReaderModalLevel {
|
||||
Panel,
|
||||
Popup
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Popup,
|
||||
ChromeTop,
|
||||
ChromeBottom
|
||||
}
|
||||
|
||||
val SharedReaderPopupDefaultMaxWidth = 440.dp
|
||||
|
|
@ -42,6 +46,14 @@ internal expect fun SharedReaderModalLayer(
|
|||
content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
internal expect fun sharedReaderModalLayerUsesSizedEdgeWindow(level: SharedReaderModalLevel): Boolean
|
||||
|
||||
@Composable
|
||||
expect fun SharedReaderModalOwnerWindowProvider(
|
||||
ownerWindow: Any?,
|
||||
content: @Composable () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SharedReaderPopupLayer(
|
||||
onDismiss: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ fun SharedSettingsHub(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
label = { Text("Search settings") }
|
||||
label = { Text(readerString("desktop_search_settings", "Search settings")) }
|
||||
)
|
||||
|
||||
when {
|
||||
|
|
@ -185,7 +185,7 @@ private fun SharedSettingsHeader(
|
|||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (canNavigateUp) {
|
||||
TextButton(onClick = onNavigateUp) {
|
||||
Text("Back")
|
||||
Text(readerString("action_back", "Back"))
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
|
|
@ -340,7 +340,7 @@ private fun SharedSettingsEmptySearch(modifier: Modifier) {
|
|||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||
Text("No settings found", fontWeight = FontWeight.SemiBold)
|
||||
Text(readerString("desktop_no_settings_found", "No settings found"), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -536,9 +536,12 @@ private fun SharedSettingsDetailPage(
|
|||
}
|
||||
SharedSettingsDestination.PDF_APPEARANCE_DEFAULTS -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Fixed-layout appearance", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(readerString("desktop_fixed_layout_appearance", "Fixed-layout appearance"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader.",
|
||||
readerString(
|
||||
"desktop_pdf_appearance_defaults_desc",
|
||||
"These defaults apply where the platform supports shared PDF appearance. Per-book PDF overrides stay in the PDF reader."
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
SharedReaderThemeControls(
|
||||
|
|
@ -549,18 +552,18 @@ private fun SharedSettingsDetailPage(
|
|||
onSettingsChange = onPdfSettingsChange
|
||||
)
|
||||
HorizontalDivider()
|
||||
Text("Visual options", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(readerString("visual_options_title", "Visual options"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = "Remove gap between pages",
|
||||
summary = "Applies to vertical PDF reading mode.",
|
||||
title = readerString("visual_options_remove_page_gap", "Remove gap between pages"),
|
||||
summary = readerString("desktop_remove_gap_between_pages_desc", "Applies to vertical reading mode."),
|
||||
checked = !pdfSettings.pdfVerticalPageGapVisible,
|
||||
onCheckedChange = { removeGap ->
|
||||
onPdfSettingsChange(pdfSettings.copy(pdfVerticalPageGapVisible = !removeGap))
|
||||
}
|
||||
)
|
||||
SharedPdfVisualOptionDefaultsSwitch(
|
||||
title = "Hide page number overlay",
|
||||
summary = "Removes the small page count label from each PDF page.",
|
||||
title = readerString("visual_options_hide_page_number_overlay", "Hide page number overlay"),
|
||||
summary = readerString("visual_options_hide_page_number_overlay_desc", "Removes the small page count label from each page."),
|
||||
checked = !pdfSettings.pdfPageNumberOverlayVisible,
|
||||
onCheckedChange = { hideOverlay ->
|
||||
onPdfSettingsChange(pdfSettings.copy(pdfPageNumberOverlayVisible = !hideOverlay))
|
||||
|
|
@ -570,16 +573,19 @@ private fun SharedSettingsDetailPage(
|
|||
}
|
||||
SharedSettingsDestination.PDF_READER_TOOLS -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Reader-managed PDF tools", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(readerString("desktop_reader_managed_pdf_tools", "Reader-managed PDF tools"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader.",
|
||||
readerString(
|
||||
"desktop_reader_managed_pdf_tools_desc",
|
||||
"Auto-scroll, OCR, annotation defaults, and PDF-only tool visibility are managed inside the active PDF reader."
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedSettingsDestination.READER_TOOLBAR_DEFAULTS -> {
|
||||
if (toolbarPreferences == null) {
|
||||
Text("Reader toolbar defaults are managed from the reader on this platform.")
|
||||
Text(readerString("desktop_reader_toolbar_managed_in_reader", "Reader toolbar defaults are managed from the reader on this platform."))
|
||||
} else {
|
||||
SharedReaderToolbarControls(
|
||||
toolbarPreferences = toolbarPreferences,
|
||||
|
|
@ -717,6 +723,7 @@ private fun SharedSettingsAction.iconForSettings(): ImageVector {
|
|||
SharedSettingsAction.TABS_TOGGLE,
|
||||
SharedSettingsAction.RECENT_LIMIT,
|
||||
SharedSettingsAction.STRICT_FILE_FILTER,
|
||||
SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME,
|
||||
SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR,
|
||||
SharedSettingsAction.SCREEN_CAPTURE_PROTECTION -> Icons.Default.Settings
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.aryan.reader.shared.BannerMessage
|
||||
import com.aryan.reader.shared.SharedText
|
||||
|
||||
@Immutable
|
||||
class SharedStringResolver(
|
||||
private val resolve: (name: String) -> String? = { null },
|
||||
private val resolveQuantity: (name: String, quantity: Int) -> String? = { _, _ -> null }
|
||||
) {
|
||||
fun string(name: String, fallback: String, vararg args: Any?): String {
|
||||
val template = resolve(name).takeUnless { it.isNullOrBlank() } ?: fallback
|
||||
return formatAndroidString(template, args.toList())
|
||||
}
|
||||
|
||||
fun quantityString(
|
||||
name: String,
|
||||
quantity: Int,
|
||||
fallbackOne: String,
|
||||
fallbackOther: String,
|
||||
vararg args: Any?
|
||||
): String {
|
||||
val fallback = if (quantity == 1) fallbackOne else fallbackOther
|
||||
val template = resolveQuantity(name, quantity).takeUnless { it.isNullOrBlank() } ?: fallback
|
||||
return formatAndroidString(template, args.toList())
|
||||
}
|
||||
|
||||
fun sharedText(text: SharedText): String {
|
||||
val quantity = text.quantity
|
||||
return if (quantity == null) {
|
||||
string(text.name, text.fallback, *text.args.toTypedArray())
|
||||
} else {
|
||||
quantityString(text.name, quantity, text.fallback, text.fallbackOther, *text.args.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalSharedStringResolver = staticCompositionLocalOf { SharedStringResolver() }
|
||||
|
||||
@Composable
|
||||
fun readerString(name: String, fallback: String, vararg args: Any?): String {
|
||||
return LocalSharedStringResolver.current.string(name, fallback, *args)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun readerQuantityString(
|
||||
name: String,
|
||||
quantity: Int,
|
||||
fallbackOne: String,
|
||||
fallbackOther: String,
|
||||
vararg args: Any?
|
||||
): String {
|
||||
return LocalSharedStringResolver.current.quantityString(name, quantity, fallbackOne, fallbackOther, *args)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun readerSharedText(text: SharedText): String {
|
||||
return LocalSharedStringResolver.current.sharedText(text)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun readerBannerMessage(message: BannerMessage?): String {
|
||||
val text = message?.text ?: return message?.message.orEmpty()
|
||||
return readerSharedText(text)
|
||||
}
|
||||
|
||||
internal fun formatAndroidString(template: String, args: List<Any?>): String {
|
||||
if (args.isEmpty()) return template.replace("%%", "%")
|
||||
|
||||
val percentPlaceholder = "\u0000PERCENT\u0000"
|
||||
var sequentialIndex = 0
|
||||
var formatted = template.replace("%%", percentPlaceholder)
|
||||
|
||||
formatted = Regex("%(\\d+)\\$[-+#, .(]*\\d*(?:\\.\\d+)?[a-zA-Z]").replace(formatted) { match ->
|
||||
val argIndex = match.groupValues[1].toIntOrNull()?.minus(1)
|
||||
args.getOrNull(argIndex ?: -1).toAndroidStringArgument()
|
||||
}
|
||||
|
||||
formatted = Regex("%[-+#, .(]*\\d*(?:\\.\\d+)?[a-zA-Z]").replace(formatted) {
|
||||
args.getOrNull(sequentialIndex++).toAndroidStringArgument()
|
||||
}
|
||||
|
||||
return formatted.replace(percentPlaceholder, "%")
|
||||
}
|
||||
|
||||
private fun Any?.toAndroidStringArgument(): String {
|
||||
return when (this) {
|
||||
null -> ""
|
||||
is Float -> trimTrailingZeroDecimal(toString())
|
||||
is Double -> trimTrailingZeroDecimal(toString())
|
||||
else -> toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun trimTrailingZeroDecimal(value: String): String {
|
||||
return value.removeSuffix(".0")
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.aryan.reader.shared.ui
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal object SharedUiTokens {
|
||||
val chromeRadius = 6.dp
|
||||
val surfaceRadius = 8.dp
|
||||
val screenPadding = 20.dp
|
||||
val panelPadding = 14.dp
|
||||
val compactGap = 8.dp
|
||||
val contentGap = 12.dp
|
||||
val sidebarWidth = 220.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun sharedSubtleBorder(alpha: Float = 0.38f): BorderStroke {
|
||||
return BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = alpha))
|
||||
}
|
||||
|
|
@ -16,9 +16,11 @@ import androidx.compose.foundation.layout.heightIn
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
|
|
@ -39,11 +41,14 @@ import androidx.compose.material3.Button
|
|||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -59,11 +64,14 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aryan.reader.shared.AppFontPreference
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
|
||||
@Composable
|
||||
fun SharedCustomFontsScreen(
|
||||
fonts: List<CustomFontItem>,
|
||||
appFontPreference: AppFontPreference = AppFontPreference.System,
|
||||
onAppFontPreferenceChange: (AppFontPreference) -> Unit = {},
|
||||
onImportFont: () -> Unit,
|
||||
onDeleteFont: (CustomFontItem) -> Unit,
|
||||
googleFontsAvailable: Boolean = false,
|
||||
|
|
@ -74,52 +82,74 @@ fun SharedCustomFontsScreen(
|
|||
) {
|
||||
var fontPendingDelete by remember { mutableStateOf<CustomFontItem?>(null) }
|
||||
var showGoogleFontsDialog by remember { mutableStateOf(false) }
|
||||
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
|
||||
|
||||
SharedScreenScaffold(
|
||||
title = "Custom Fonts",
|
||||
subtitle = "Imported fonts for the reader",
|
||||
title = readerString("custom_fonts", "Custom fonts"),
|
||||
subtitle = readerString("desktop_custom_fonts_desc", "Imported fonts for the reader"),
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (googleFontsAvailable) {
|
||||
Button(onClick = { showGoogleFontsDialog = true }) {
|
||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Google Fonts")
|
||||
if (selectedSection == SharedFontSettingsSection.READER_FONTS) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (googleFontsAvailable) {
|
||||
Button(onClick = { showGoogleFontsDialog = true }) {
|
||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("google_fonts", "Google Fonts"))
|
||||
}
|
||||
}
|
||||
Button(onClick = onImportFont) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(readerString("action_import", "Import"))
|
||||
}
|
||||
}
|
||||
Button(onClick = onImportFont) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
val activeFonts = fonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
|
||||
if (activeFonts.isEmpty()) {
|
||||
SharedUtilityEmptyState(
|
||||
icon = { Icon(Icons.Default.TextFields, contentDescription = null, modifier = Modifier.size(56.dp)) },
|
||||
title = "No custom fonts",
|
||||
body = "Import TTF, OTF, or WOFF2 files to use them in books.",
|
||||
actionLabel = "Import font",
|
||||
onAction = onImportFont,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(activeFonts, key = { it.id }) { font ->
|
||||
SharedFontListItem(
|
||||
font = font,
|
||||
onDelete = { fontPendingDelete = font },
|
||||
fontFamilyForPreview = fontFamilyForPreview
|
||||
SharedFontSettingsTabs(
|
||||
selectedSection = selectedSection,
|
||||
onSectionChange = { selectedSection = it }
|
||||
)
|
||||
|
||||
when (selectedSection) {
|
||||
SharedFontSettingsSection.READER_FONTS -> {
|
||||
if (activeFonts.isEmpty()) {
|
||||
SharedUtilityEmptyState(
|
||||
icon = { Icon(Icons.Default.TextFields, contentDescription = null, modifier = Modifier.size(56.dp)) },
|
||||
title = readerString("no_custom_fonts", "No custom fonts"),
|
||||
body = readerString("desktop_no_custom_fonts_desc", "Import TTF, OTF, or WOFF2 files to use them in books."),
|
||||
actionLabel = readerString("import_font", "Import font"),
|
||||
onAction = onImportFont,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(activeFonts, key = { it.id }) { font ->
|
||||
SharedFontListItem(
|
||||
font = font,
|
||||
onDelete = { fontPendingDelete = font },
|
||||
fontFamilyForPreview = fontFamilyForPreview
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SharedFontSettingsSection.APP_TEXT -> {
|
||||
SharedAppFontSelector(
|
||||
preference = appFontPreference,
|
||||
customFonts = activeFonts,
|
||||
onPreferenceChange = onAppFontPreferenceChange,
|
||||
fontFamilyForPreview = fontFamilyForPreview,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,8 +165,8 @@ fun SharedCustomFontsScreen(
|
|||
fontPendingDelete?.let { font ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { fontPendingDelete = null },
|
||||
title = { Text("Delete font?") },
|
||||
text = { Text("Delete ${font.displayName}? Books using it will fall back to the default font.") },
|
||||
title = { Text(readerString("dialog_delete_font", "Delete font?")) },
|
||||
text = { Text(readerString("desktop_delete_font_desc", "Delete %1\$s? Books using it will fall back to the default font.", font.displayName)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
|
|
@ -144,18 +174,46 @@ fun SharedCustomFontsScreen(
|
|||
fontPendingDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
Text(readerString("action_delete", "Delete"), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { fontPendingDelete = null }) {
|
||||
Text("Cancel")
|
||||
Text(readerString("action_cancel", "Cancel"))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class SharedFontSettingsSection {
|
||||
READER_FONTS,
|
||||
APP_TEXT
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedFontSettingsTabs(
|
||||
selectedSection: SharedFontSettingsSection,
|
||||
onSectionChange: (SharedFontSettingsSection) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedSection.ordinal,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Tab(
|
||||
selected = selectedSection == SharedFontSettingsSection.READER_FONTS,
|
||||
onClick = { onSectionChange(SharedFontSettingsSection.READER_FONTS) },
|
||||
text = { Text(readerString("reader_fonts", "Reader fonts")) }
|
||||
)
|
||||
Tab(
|
||||
selected = selectedSection == SharedFontSettingsSection.APP_TEXT,
|
||||
onClick = { onSectionChange(SharedFontSettingsSection.APP_TEXT) },
|
||||
text = { Text(readerString("app_font_title", "App text font")) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedGoogleFontsDialog(
|
||||
existingFonts: List<CustomFontItem>,
|
||||
|
|
@ -199,7 +257,7 @@ private fun SharedGoogleFontsDialog(
|
|||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text("Browse Google Fonts", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("action_browse_google_fonts", "Browse Google Fonts"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
|
|
@ -210,7 +268,7 @@ private fun SharedGoogleFontsDialog(
|
|||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search 1900+ fonts...") },
|
||||
placeholder = { Text(readerString("google_fonts_search_placeholder", "Search 1900+ fonts...")) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
|
|
@ -224,7 +282,7 @@ private fun SharedGoogleFontsDialog(
|
|||
if (searchQuery.isBlank()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Popular choices",
|
||||
text = readerString("google_fonts_popular_choices", "Popular choices"),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
|
@ -232,7 +290,7 @@ private fun SharedGoogleFontsDialog(
|
|||
} else if (displayList.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No fonts found matching '$searchQuery'",
|
||||
text = readerString("desktop_no_fonts_matching", "No fonts found matching '%1\$s'", searchQuery),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
|
|
@ -275,9 +333,9 @@ private fun SharedGoogleFontsDialog(
|
|||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when {
|
||||
isDownloaded -> Icon(Icons.Default.Check, contentDescription = "Already downloaded", tint = MaterialTheme.colorScheme.primary)
|
||||
isDownloaded -> Icon(Icons.Default.Check, contentDescription = readerString("content_desc_already_downloaded", "Already downloaded"), tint = MaterialTheme.colorScheme.primary)
|
||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.CloudDownload, contentDescription = "Download")
|
||||
else -> Icon(Icons.Default.CloudDownload, contentDescription = readerString("action_download", "Download"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -287,7 +345,7 @@ private fun SharedGoogleFontsDialog(
|
|||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Close")
|
||||
Text(readerString("action_close", "Close"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -348,7 +406,7 @@ private fun SharedFontListItem(
|
|||
)
|
||||
}
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete font", tint = MaterialTheme.colorScheme.error)
|
||||
Icon(Icons.Default.Delete, contentDescription = readerString("desktop_delete_font", "Delete font"), tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
|
|
@ -358,7 +416,7 @@ private fun SharedFontListItem(
|
|||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:",
|
||||
text = readerString("font_preview_text", "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:"),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 18.sp),
|
||||
fontFamily = previewFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
|
|
@ -375,24 +433,24 @@ fun SharedHelpFeedbackScreen(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "Help & Feedback",
|
||||
subtitle = "Bug reports, feature requests, and support",
|
||||
title = readerString("drawer_help_feedback", "Help & Feedback"),
|
||||
subtitle = readerString("desktop_help_feedback_desc", "Bug reports, feature requests, and support"),
|
||||
modifier = modifier
|
||||
) {
|
||||
SharedUtilityHeader(
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(52.dp)) },
|
||||
title = "Get in touch",
|
||||
body = "Report bugs, request features, or contact support directly."
|
||||
title = readerString("get_in_touch", "Get in touch"),
|
||||
body = readerString("desktop_get_in_touch_desc", "Report bugs, request features, or contact support directly.")
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "GitHub Issues",
|
||||
body = "Report bugs, request features, and track development progress.",
|
||||
title = readerString("github_issues", "GitHub Issues"),
|
||||
body = readerString("github_issues_desc", "Report bugs, request features, and track development progress."),
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenGitHubIssues
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Email Support",
|
||||
body = "Contact us directly by email for anything else.",
|
||||
title = readerString("email_support", "Email support"),
|
||||
body = readerString("desktop_email_support_desc", "Contact us directly by email for anything else."),
|
||||
icon = { Icon(Icons.Default.Email, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onEmailSupport
|
||||
)
|
||||
|
|
@ -406,24 +464,24 @@ fun SharedSupportProjectScreen(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "Support Project",
|
||||
subtitle = "Ways to support Episteme development",
|
||||
title = readerString("drawer_support_project", "Support project"),
|
||||
subtitle = readerString("desktop_support_project_desc", "Ways to support Episteme development"),
|
||||
modifier = modifier
|
||||
) {
|
||||
SharedUtilityHeader(
|
||||
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(52.dp)) },
|
||||
title = "Support Episteme",
|
||||
body = "Contributions help keep the reader improving across Android and desktop."
|
||||
title = readerString("desktop_support_episteme", "Support Episteme"),
|
||||
body = readerString("desktop_support_episteme_desc", "Contributions help keep the reader improving across Android and desktop.")
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "GitHub Sponsors",
|
||||
body = "Support development through GitHub Sponsors.",
|
||||
title = readerString("desktop_github_sponsors", "GitHub Sponsors"),
|
||||
body = readerString("desktop_github_sponsors_desc", "Support development through GitHub Sponsors."),
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenGitHubSponsors
|
||||
)
|
||||
SharedUtilityOptionCard(
|
||||
title = "Patreon",
|
||||
body = "Support the project on Patreon.",
|
||||
title = readerString("desktop_patreon", "Patreon"),
|
||||
body = readerString("desktop_patreon_desc", "Support the project on Patreon."),
|
||||
icon = { Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenPatreon
|
||||
)
|
||||
|
|
@ -439,8 +497,8 @@ fun SharedAboutScreen(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SharedScreenScaffold(
|
||||
title = "About Episteme",
|
||||
subtitle = "Desktop reader",
|
||||
title = readerString("about_title", "About Episteme"),
|
||||
subtitle = readerString("desktop_about_subtitle", "Desktop reader"),
|
||||
modifier = modifier
|
||||
) {
|
||||
Surface(
|
||||
|
|
@ -464,7 +522,7 @@ fun SharedAboutScreen(
|
|||
}
|
||||
}
|
||||
Column {
|
||||
Text("Episteme", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(readerString("app_name", "Episteme"), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(versionName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(buildLabel, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
|
@ -472,16 +530,16 @@ fun SharedAboutScreen(
|
|||
}
|
||||
if (onOpenSource != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = "Source Code",
|
||||
body = "Browse the project source on GitHub.",
|
||||
title = readerString("desktop_source_code", "Source code"),
|
||||
body = readerString("desktop_source_code_desc", "Browse the project source on GitHub."),
|
||||
icon = { Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenSource
|
||||
)
|
||||
}
|
||||
if (onOpenIssues != null) {
|
||||
SharedUtilityOptionCard(
|
||||
title = "Issues",
|
||||
body = "Open the issue tracker for bugs and feature requests.",
|
||||
title = readerString("desktop_issues", "Issues"),
|
||||
body = readerString("desktop_issues_desc", "Open the issue tracker for bugs and feature requests."),
|
||||
icon = { Icon(Icons.Default.Feedback, contentDescription = null, modifier = Modifier.size(28.dp)) },
|
||||
onClick = onOpenIssues
|
||||
)
|
||||
|
|
@ -557,7 +615,181 @@ private fun SharedUtilityOptionCard(
|
|||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = readerString("action_open", "Open"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SharedAppFontSelector(
|
||||
preference: AppFontPreference,
|
||||
customFonts: List<CustomFontItem>,
|
||||
onPreferenceChange: (AppFontPreference) -> Unit,
|
||||
fontFamilyForPreview: (CustomFontItem) -> FontFamily? = { null },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val sanitizedPreference = preference.sanitized()
|
||||
val activeFonts = customFonts.filterNot { it.isDeleted }.sortedBy { it.displayName.lowercase() }
|
||||
OutlinedCard(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = readerString("app_font_title", "App text font"),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = readerString("app_font_desc", "Applies to app navigation, settings, lists, dialogs, and reader chrome."),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
listOf(
|
||||
SharedAppFontOption(
|
||||
label = readerString("app_font_system", "System"),
|
||||
summary = readerString("app_font_system_desc", "Use the platform default"),
|
||||
preference = AppFontPreference.System,
|
||||
fontFamily = FontFamily.Default
|
||||
),
|
||||
SharedAppFontOption(
|
||||
label = readerString("app_font_serif", "Serif"),
|
||||
summary = readerString("app_font_serif_desc", "Classic reading-style letterforms"),
|
||||
preference = AppFontPreference.Serif,
|
||||
fontFamily = FontFamily.Serif
|
||||
),
|
||||
SharedAppFontOption(
|
||||
label = readerString("app_font_sans", "Sans"),
|
||||
summary = readerString("app_font_sans_desc", "Clean interface-style letterforms"),
|
||||
preference = AppFontPreference.SansSerif,
|
||||
fontFamily = FontFamily.SansSerif
|
||||
),
|
||||
SharedAppFontOption(
|
||||
label = readerString("app_font_monospace", "Monospace"),
|
||||
summary = readerString("app_font_monospace_desc", "Fixed-width text"),
|
||||
preference = AppFontPreference.Monospace,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
).forEach { option ->
|
||||
SharedAppFontOptionRow(
|
||||
label = option.label,
|
||||
summary = option.summary,
|
||||
selected = sanitizedPreference == option.preference,
|
||||
fontFamily = option.fontFamily,
|
||||
onClick = { onPreferenceChange(option.preference) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = readerString("app_font_imported_fonts", "Imported fonts"),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (activeFonts.isEmpty()) {
|
||||
Text(
|
||||
text = readerString("app_font_no_imported_fonts", "Import a font to use it for app text."),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
activeFonts.forEach { font ->
|
||||
val previewFontFamily = remember(font.path) { fontFamilyForPreview(font) }
|
||||
val option = AppFontPreference.custom(font.id)
|
||||
SharedAppFontOptionRow(
|
||||
label = font.displayName,
|
||||
summary = font.fileExtension.uppercase(),
|
||||
selected = sanitizedPreference == option,
|
||||
fontFamily = previewFontFamily,
|
||||
onClick = { onPreferenceChange(option) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class SharedAppFontOption(
|
||||
val label: String,
|
||||
val summary: String,
|
||||
val preference: AppFontPreference,
|
||||
val fontFamily: FontFamily?
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SharedAppFontOptionRow(
|
||||
label: String,
|
||||
summary: String,
|
||||
selected: Boolean,
|
||||
fontFamily: FontFamily?,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerLow
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = containerColor,
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
) {
|
||||
Box(Modifier.size(38.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Aa", fontFamily = fontFamily, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = fontFamily),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (selected) {
|
||||
Icon(Icons.Default.Check, contentDescription = readerString("content_desc_selected", "Selected"), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue