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:
Aryan 2026-05-20 22:14:01 +05:30 committed by GitHub
parent dc5196526f
commit 9510293ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 37538 additions and 12460 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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