* 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
1174 lines
52 KiB
Kotlin
1174 lines
52 KiB
Kotlin
/*
|
|
* Episteme Reader - A native Android document reader.
|
|
* Copyright (C) 2026 Episteme
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* mail: epistemereader@gmail.com
|
|
*/
|
|
package com.aryan.reader.epubreader
|
|
|
|
import android.content.Context
|
|
import android.net.Uri
|
|
import androidx.annotation.StringRes
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.PaddingValues
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.fillMaxHeight
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.Canvas
|
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
import androidx.compose.foundation.gestures.drag
|
|
import androidx.compose.ui.geometry.CornerRadius
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.geometry.Size
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.platform.LocalConfiguration
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.heightIn
|
|
import androidx.compose.foundation.layout.navigationBars
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|
import androidx.compose.material.icons.filled.Add
|
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
import androidx.compose.material.icons.filled.Check
|
|
import androidx.compose.material.icons.filled.Close
|
|
import androidx.compose.material.icons.filled.Remove
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.DropdownMenu
|
|
import androidx.compose.material3.DropdownMenuItem
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.HorizontalDivider
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.ListItem
|
|
import androidx.compose.material3.ListItemDefaults
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.ModalBottomSheet
|
|
import androidx.compose.material3.Slider
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Switch
|
|
import androidx.compose.material3.Tab
|
|
import androidx.compose.material3.TabRow
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.material3.rememberModalBottomSheetState
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableFloatStateOf
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.font.Font
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.core.content.edit
|
|
import com.aryan.reader.R
|
|
import com.aryan.reader.data.CustomFontEntity
|
|
import java.io.File
|
|
import kotlin.math.roundToInt
|
|
|
|
typealias ReaderFont = com.aryan.reader.shared.ReaderFont
|
|
typealias ReaderTextAlign = com.aryan.reader.shared.ReaderTextAlign
|
|
typealias SystemUiMode = com.aryan.reader.shared.SystemUiMode
|
|
typealias PageInfoMode = com.aryan.reader.shared.PageInfoMode
|
|
typealias PageInfoPosition = com.aryan.reader.shared.PageInfoPosition
|
|
typealias FormatSettings = com.aryan.reader.shared.FormatSettings
|
|
|
|
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
|
|
private const val TEXT_ALIGN_KEY = "reader_text_align"
|
|
private const val FONT_SIZE_KEY = "reader_font_size"
|
|
private const val LINE_HEIGHT_KEY = "reader_line_height"
|
|
private const val PARAGRAPH_GAP_KEY = "reader_paragraph_gap"
|
|
private const val IMAGE_SIZE_KEY = "reader_image_size"
|
|
private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed"
|
|
private const val FONT_FAMILY_KEY = "reader_font_family"
|
|
private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
|
|
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
|
|
private const val SYSTEM_UI_MODE_KEY = "reader_system_ui_mode"
|
|
private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
|
|
private const val PAGE_INFO_POSITION_KEY = "reader_page_info_position"
|
|
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
|
|
|
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
|
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
|
|
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
|
|
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
|
|
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
|
|
const val DEFAULT_VERTICAL_MARGIN_VAL = 1.0f
|
|
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
|
|
private const val TTS_PITCH_KEY = "tts_pitch"
|
|
|
|
fun saveTtsSpeechRate(context: Context, rate: Float) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putFloat(TTS_SPEECH_RATE_KEY, rate) }
|
|
}
|
|
|
|
fun loadTtsSpeechRate(context: Context): Float {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getFloat(TTS_SPEECH_RATE_KEY, 1.0f)
|
|
}
|
|
|
|
fun saveTtsPitch(context: Context, pitch: Float) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putFloat(TTS_PITCH_KEY, pitch) }
|
|
}
|
|
|
|
fun loadTtsPitch(context: Context): Float {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getFloat(TTS_PITCH_KEY, 1.0f)
|
|
}
|
|
|
|
val ReaderTextAlign.iconResId: Int
|
|
get() = when (this) {
|
|
ReaderTextAlign.DEFAULT,
|
|
ReaderTextAlign.LEFT -> R.drawable.format_align_left
|
|
ReaderTextAlign.RIGHT -> R.drawable.format_align_right
|
|
ReaderTextAlign.JUSTIFY -> R.drawable.format_align_justify
|
|
}
|
|
|
|
@get:StringRes
|
|
val ReaderTextAlign.displayNameRes: Int
|
|
get() = when (this) {
|
|
ReaderTextAlign.DEFAULT -> R.string.label_default
|
|
ReaderTextAlign.LEFT -> R.string.label_left
|
|
ReaderTextAlign.RIGHT -> R.string.label_right
|
|
ReaderTextAlign.JUSTIFY -> R.string.label_justify
|
|
}
|
|
|
|
@get:StringRes
|
|
val SystemUiMode.titleRes: Int
|
|
get() = when (this) {
|
|
SystemUiMode.DEFAULT -> R.string.label_always_show
|
|
SystemUiMode.SYNC -> R.string.label_sync_with_menus
|
|
SystemUiMode.HIDDEN -> R.string.label_always_hide
|
|
}
|
|
|
|
@get:StringRes
|
|
val PageInfoMode.titleRes: Int
|
|
get() = when (this) {
|
|
PageInfoMode.DEFAULT -> R.string.label_always_show
|
|
PageInfoMode.SYNC -> R.string.label_sync_with_menus
|
|
PageInfoMode.HIDDEN -> R.string.label_always_hide
|
|
}
|
|
|
|
@get:StringRes
|
|
val PageInfoPosition.titleRes: Int
|
|
get() = when (this) {
|
|
PageInfoPosition.BOTTOM -> R.string.label_bottom
|
|
PageInfoPosition.TOP -> R.string.label_top
|
|
}
|
|
|
|
private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_"
|
|
private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_"
|
|
private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
|
|
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
|
|
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
|
|
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
|
|
private const val LOCAL_VERTICAL_MARGIN_PREFIX = "local_vertical_margin_"
|
|
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
|
|
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
|
|
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
|
|
private const val VERTICAL_MARGIN_KEY = "reader_vertical_margin"
|
|
|
|
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getBoolean(FORMAT_IS_LOCAL_PREFIX + bookId, false)
|
|
}
|
|
|
|
fun saveFormatIsLocal(context: Context, bookId: String, isLocal: Boolean) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putBoolean(FORMAT_IS_LOCAL_PREFIX + bookId, isLocal) }
|
|
}
|
|
|
|
fun saveLocalReaderSettings(
|
|
context: Context,
|
|
bookId: String,
|
|
fontSize: Float,
|
|
lineHeight: Float,
|
|
paragraphGap: Float,
|
|
imageSize: Float,
|
|
horizontalMargin: Float,
|
|
verticalMargin: Float,
|
|
fontFamily: ReaderFont,
|
|
customFontPath: String?,
|
|
textAlign: ReaderTextAlign
|
|
) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit {
|
|
putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize)
|
|
putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight)
|
|
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
|
|
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
|
|
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
|
|
putFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, verticalMargin)
|
|
if (customFontPath != null) {
|
|
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
|
|
} else {
|
|
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, fontFamily.id)
|
|
}
|
|
putString(LOCAL_TEXT_ALIGN_PREFIX + bookId, textAlign.id)
|
|
}
|
|
}
|
|
|
|
fun saveSystemUiMode(context: Context, mode: SystemUiMode) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putInt(SYSTEM_UI_MODE_KEY, mode.id) }
|
|
}
|
|
|
|
fun loadSystemUiMode(context: Context): SystemUiMode {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
val id = prefs.getInt(SYSTEM_UI_MODE_KEY, SystemUiMode.DEFAULT.id)
|
|
return SystemUiMode.entries.find { it.id == id } ?: SystemUiMode.DEFAULT
|
|
}
|
|
|
|
fun savePageInfoMode(context: Context, mode: PageInfoMode) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putInt(PAGE_INFO_MODE_KEY, mode.id) }
|
|
}
|
|
|
|
fun loadPageInfoMode(context: Context): PageInfoMode {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
val id = prefs.getInt(PAGE_INFO_MODE_KEY, PageInfoMode.DEFAULT.id)
|
|
return PageInfoMode.entries.find { it.id == id } ?: PageInfoMode.DEFAULT
|
|
}
|
|
|
|
fun savePageInfoPosition(context: Context, position: PageInfoPosition) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putInt(PAGE_INFO_POSITION_KEY, position.id) }
|
|
}
|
|
|
|
fun loadPageInfoPosition(context: Context): PageInfoPosition {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
val id = prefs.getInt(PAGE_INFO_POSITION_KEY, PageInfoPosition.BOTTOM.id)
|
|
return PageInfoPosition.entries.find { it.id == id } ?: PageInfoPosition.BOTTOM
|
|
}
|
|
|
|
fun savePullToTurn(context: Context, enabled: Boolean) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putBoolean(PULL_TO_TURN_ENABLED_KEY, enabled) }
|
|
}
|
|
|
|
fun loadPullToTurn(context: Context): Boolean {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getBoolean(PULL_TO_TURN_ENABLED_KEY, true)
|
|
}
|
|
|
|
private const val PULL_TO_TURN_MULTIPLIER_KEY = "reader_pull_to_turn_multiplier"
|
|
|
|
fun savePullToTurnMultiplier(context: Context, multiplier: Float) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putFloat(PULL_TO_TURN_MULTIPLIER_KEY, multiplier) }
|
|
}
|
|
|
|
fun loadPullToTurnMultiplier(context: Context): Float {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f)
|
|
}
|
|
|
|
fun loadHorizontalMargin(context: Context): Float {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
if (prefs.contains(HORIZONTAL_MARGIN_KEY)) {
|
|
return prefs.getFloat(HORIZONTAL_MARGIN_KEY, DEFAULT_HORIZONTAL_MARGIN_VAL)
|
|
}
|
|
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
|
|
}
|
|
|
|
fun loadVerticalMargin(context: Context): Float {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getFloat(VERTICAL_MARGIN_KEY, DEFAULT_VERTICAL_MARGIN_VAL)
|
|
}
|
|
|
|
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
|
|
val fontSize = if (isLocal && prefs.contains(LOCAL_FONT_SIZE_PREFIX + bookId)) {
|
|
prefs.getFloat(LOCAL_FONT_SIZE_PREFIX + bookId, DEFAULT_FONT_SIZE_VAL)
|
|
} else {
|
|
prefs.getFloat(FONT_SIZE_KEY, DEFAULT_FONT_SIZE_VAL)
|
|
}
|
|
|
|
val lineHeight = if (isLocal && prefs.contains(LOCAL_LINE_HEIGHT_PREFIX + bookId)) {
|
|
prefs.getFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, DEFAULT_LINE_HEIGHT_VAL)
|
|
} else {
|
|
prefs.getFloat(LINE_HEIGHT_KEY, DEFAULT_LINE_HEIGHT_VAL)
|
|
}
|
|
|
|
val paragraphGap = if (isLocal && prefs.contains(LOCAL_PARAGRAPH_GAP_PREFIX + bookId)) {
|
|
prefs.getFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, DEFAULT_PARAGRAPH_GAP_VAL)
|
|
} else {
|
|
prefs.getFloat(PARAGRAPH_GAP_KEY, DEFAULT_PARAGRAPH_GAP_VAL)
|
|
}
|
|
|
|
val imageSize = if (isLocal && prefs.contains(LOCAL_IMAGE_SIZE_PREFIX + bookId)) {
|
|
prefs.getFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, DEFAULT_IMAGE_SIZE_VAL)
|
|
} else {
|
|
prefs.getFloat(IMAGE_SIZE_KEY, DEFAULT_IMAGE_SIZE_VAL)
|
|
}
|
|
|
|
val horizontalMargin = if (isLocal && prefs.contains(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId)) {
|
|
prefs.getFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, DEFAULT_HORIZONTAL_MARGIN_VAL)
|
|
} else {
|
|
loadHorizontalMargin(context)
|
|
}
|
|
|
|
val verticalMargin = if (isLocal && prefs.contains(LOCAL_VERTICAL_MARGIN_PREFIX + bookId)) {
|
|
prefs.getFloat(LOCAL_VERTICAL_MARGIN_PREFIX + bookId, DEFAULT_VERTICAL_MARGIN_VAL)
|
|
} else {
|
|
loadVerticalMargin(context)
|
|
}
|
|
|
|
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
|
|
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
|
|
} else {
|
|
prefs.getString(FONT_FAMILY_KEY, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
|
|
}
|
|
|
|
val (font, customPath) = if (savedFontVal.startsWith("custom|")) {
|
|
Pair(ReaderFont.ORIGINAL, savedFontVal.substringAfter("custom|"))
|
|
} else {
|
|
Pair(ReaderFont.entries.find { it.id == savedFontVal } ?: ReaderFont.ORIGINAL, null)
|
|
}
|
|
|
|
val alignId = if (isLocal && prefs.contains(LOCAL_TEXT_ALIGN_PREFIX + bookId)) {
|
|
prefs.getString(LOCAL_TEXT_ALIGN_PREFIX + bookId, ReaderTextAlign.DEFAULT.id)
|
|
} else {
|
|
prefs.getString(TEXT_ALIGN_KEY, ReaderTextAlign.DEFAULT.id)
|
|
}
|
|
val textAlign = ReaderTextAlign.entries.find { it.id == alignId } ?: ReaderTextAlign.DEFAULT
|
|
|
|
return FormatSettings(
|
|
fontSize = fontSize,
|
|
lineHeight = lineHeight,
|
|
paragraphGap = paragraphGap,
|
|
imageSize = imageSize,
|
|
horizontalMargin = horizontalMargin,
|
|
verticalMargin = verticalMargin,
|
|
font = font,
|
|
customPath = customPath,
|
|
textAlign = textAlign
|
|
)
|
|
}
|
|
|
|
fun getComposeFontFamily(
|
|
font: ReaderFont,
|
|
customFontPath: String? = null,
|
|
assetManager: android.content.res.AssetManager? = null
|
|
): FontFamily {
|
|
if (customFontPath != null) {
|
|
return try {
|
|
FontFamily(Font(File(customFontPath)))
|
|
} catch (_: Exception) {
|
|
FontFamily.Default
|
|
}
|
|
}
|
|
|
|
if (assetManager != null) {
|
|
return try {
|
|
when (font) {
|
|
ReaderFont.ORIGINAL -> FontFamily.Default
|
|
ReaderFont.MERRIWEATHER -> FontFamily(Font("fonts/merriweather.ttf", assetManager))
|
|
ReaderFont.LATO -> FontFamily(Font("fonts/lato.ttf", assetManager))
|
|
ReaderFont.LORA -> FontFamily(Font("fonts/lora.ttf", assetManager))
|
|
ReaderFont.ROBOTO_MONO -> FontFamily(Font("fonts/roboto_mono.ttf", assetManager))
|
|
ReaderFont.LEXEND -> FontFamily(Font("fonts/lexend.ttf", assetManager))
|
|
}
|
|
} catch (_: Exception) {
|
|
FontFamily.Default
|
|
}
|
|
}
|
|
|
|
return FontFamily.Default
|
|
}
|
|
|
|
fun saveReaderSettings(
|
|
context: Context,
|
|
fontSize: Float,
|
|
lineHeight: Float,
|
|
paragraphGap: Float,
|
|
imageSize: Float,
|
|
horizontalMargin: Float,
|
|
verticalMargin: Float,
|
|
fontFamily: ReaderFont,
|
|
customFontPath: String?,
|
|
textAlign: ReaderTextAlign
|
|
) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit {
|
|
putFloat(FONT_SIZE_KEY, fontSize)
|
|
putFloat(LINE_HEIGHT_KEY, lineHeight)
|
|
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
|
|
putFloat(IMAGE_SIZE_KEY, imageSize)
|
|
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
|
|
putFloat(VERTICAL_MARGIN_KEY, verticalMargin)
|
|
if (customFontPath != null) {
|
|
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
|
|
} else {
|
|
putString(FONT_FAMILY_KEY, fontFamily.id)
|
|
}
|
|
putString(TEXT_ALIGN_KEY, textAlign.id)
|
|
}
|
|
}
|
|
|
|
fun saveAutoScrollSpeed(context: Context, speed: Float) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putFloat(AUTO_SCROLL_SPEED_KEY, speed) }
|
|
}
|
|
|
|
fun loadAutoScrollSpeed(context: Context): Float {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getFloat(AUTO_SCROLL_SPEED_KEY, 0.8f)
|
|
}
|
|
|
|
fun saveTapToNavigateSetting(context: Context, enabled: Boolean) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putBoolean(TAP_TO_NAVIGATE_ENABLED_KEY, enabled) }
|
|
}
|
|
|
|
fun loadTapToNavigateSetting(context: Context): Boolean {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getBoolean(TAP_TO_NAVIGATE_ENABLED_KEY, false)
|
|
}
|
|
|
|
fun saveVolumeScrollSetting(context: Context, enabled: Boolean) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putBoolean(VOLUME_SCROLL_ENABLED_KEY, enabled) }
|
|
}
|
|
|
|
fun loadVolumeScrollSetting(context: Context): Boolean {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false)
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun ReaderTextFormatPanel(
|
|
isVisible: Boolean,
|
|
currentFontSize: Float,
|
|
onFontSizeChange: (Float) -> Unit,
|
|
currentLineHeight: Float,
|
|
onLineHeightChange: (Float) -> Unit,
|
|
currentParagraphGap: Float,
|
|
onParagraphGapChange: (Float) -> Unit,
|
|
currentImageSize: Float,
|
|
onImageSizeChange: (Float) -> Unit,
|
|
currentHorizontalMargin: Float,
|
|
onHorizontalMarginChange: (Float) -> Unit,
|
|
currentVerticalMargin: Float,
|
|
onVerticalMarginChange: (Float) -> Unit,
|
|
currentFont: ReaderFont,
|
|
currentCustomFontName: String?,
|
|
onFontOptionClick: () -> Unit,
|
|
currentTextAlign: ReaderTextAlign,
|
|
onTextAlignChange: (ReaderTextAlign) -> Unit,
|
|
onReset: () -> Unit,
|
|
isLocalMode: Boolean,
|
|
onLocalModeToggle: (Boolean) -> Unit,
|
|
onClose: () -> Unit
|
|
) {
|
|
if (isVisible) {
|
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
|
|
ModalBottomSheet(
|
|
onDismissRequest = onClose,
|
|
sheetState = sheetState,
|
|
scrimColor = Color.Transparent,
|
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f),
|
|
contentWindowInsets = { WindowInsets.navigationBars }
|
|
) {
|
|
val configuration = LocalConfiguration.current
|
|
val maxSheetHeight = (configuration.screenHeightDp * 0.7f).dp
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.heightIn(max = maxSheetHeight)
|
|
.verticalScroll(rememberScrollState())
|
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
.padding(bottom = 24.dp)
|
|
) {
|
|
// Header Row (Local/Global + Close/Reset)
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Box {
|
|
var showModeMenu by remember { mutableStateOf(false) }
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier
|
|
.clip(RoundedCornerShape(8.dp))
|
|
.clickable { showModeMenu = true }
|
|
.padding(4.dp)
|
|
) {
|
|
Text(
|
|
text = if (isLocalMode) stringResource(R.string.format_local) else stringResource(R.string.format_global),
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.primary
|
|
)
|
|
Icon(
|
|
imageVector = Icons.Default.ArrowDropDown,
|
|
contentDescription = stringResource(R.string.content_desc_select_mode),
|
|
tint = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
|
|
DropdownMenu(expanded = showModeMenu, onDismissRequest = { showModeMenu = false }) {
|
|
DropdownMenuItem(
|
|
text = {
|
|
Column {
|
|
Text(stringResource(R.string.format_global), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
|
Text(stringResource(R.string.auto_scroll_applies_all_files), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
},
|
|
onClick = { onLocalModeToggle(false); showModeMenu = false },
|
|
trailingIcon = { if (!isLocalMode) Icon(Icons.Default.Check, null) }
|
|
)
|
|
HorizontalDivider()
|
|
DropdownMenuItem(
|
|
text = {
|
|
Column {
|
|
Text(stringResource(R.string.format_local), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
|
Text(stringResource(R.string.auto_scroll_saved_for_file), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
},
|
|
onClick = { onLocalModeToggle(true); showModeMenu = false },
|
|
trailingIcon = { if (isLocalMode) Icon(Icons.Default.Check, null) }
|
|
)
|
|
}
|
|
}
|
|
|
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
TextButton(onClick = onReset, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
|
Text(stringResource(R.string.action_reset))
|
|
}
|
|
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
|
|
Icon(Icons.Default.Close, stringResource(R.string.action_close), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
// FONT & ALIGNMENT SECTION
|
|
Text(
|
|
text = stringResource(R.string.section_font_alignment),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
fontWeight = FontWeight.Bold,
|
|
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
|
|
)
|
|
|
|
// Font Button
|
|
Surface(
|
|
onClick = onFontOptionClick,
|
|
shape = RoundedCornerShape(12.dp),
|
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(52.dp)
|
|
) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
modifier = Modifier.padding(horizontal = 16.dp)
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
text = stringResource(R.string.label_aa_preview),
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.Bold,
|
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
modifier = Modifier.padding(end = 12.dp)
|
|
)
|
|
Text(
|
|
text = currentCustomFontName ?: currentFont.displayName,
|
|
style = MaterialTheme.typography.titleSmall,
|
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
maxLines = 1, overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
Icon(
|
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(8.dp))
|
|
|
|
// Alignment Button (Segmented)
|
|
Surface(
|
|
shape = RoundedCornerShape(12.dp),
|
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(48.dp)
|
|
) {
|
|
Row {
|
|
ReaderTextAlign.entries.forEach { align ->
|
|
val isSelected = currentTextAlign == align
|
|
val alignDisplayName = stringResource(align.displayNameRes)
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxHeight()
|
|
.weight(1f)
|
|
.clip(RoundedCornerShape(12.dp))
|
|
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
|
.clickable { onTextAlignChange(align) },
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.Center
|
|
) {
|
|
Icon(
|
|
painter = painterResource(id = align.iconResId),
|
|
contentDescription = alignDisplayName,
|
|
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
Text(
|
|
text = alignDisplayName,
|
|
style = MaterialTheme.typography.labelSmall,
|
|
fontSize = 11.sp,
|
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(24.dp))
|
|
|
|
// LAYOUT & SPACING SECTION
|
|
Text(
|
|
text = stringResource(R.string.section_layout_spacing),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
fontWeight = FontWeight.Bold,
|
|
modifier = Modifier.padding(start = 4.dp, bottom = 12.dp)
|
|
)
|
|
|
|
// Resolve the string once outside the lambdas
|
|
val originalLabel = stringResource(R.string.label_original)
|
|
val noneLabel = stringResource(R.string.label_none)
|
|
|
|
// Wide, smooth sliders without dots
|
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
FormatSlider(
|
|
label = stringResource(R.string.label_font_size),
|
|
value = currentFontSize,
|
|
onValueChange = onFontSizeChange,
|
|
valueRange = 0.5f..3.0f,
|
|
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
|
|
)
|
|
|
|
FormatSlider(
|
|
label = stringResource(R.string.label_line_height),
|
|
value = currentLineHeight,
|
|
onValueChange = onLineHeightChange,
|
|
valueRange = 1.0f..3.0f,
|
|
formatValue = { if (it <= 1.01f) originalLabel else "%.1fx".format(it) }
|
|
)
|
|
|
|
FormatSlider(
|
|
label = stringResource(R.string.label_paragraph_gap),
|
|
value = currentParagraphGap,
|
|
onValueChange = onParagraphGapChange,
|
|
valueRange = 0.0f..3.0f,
|
|
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
|
|
)
|
|
|
|
FormatSlider(
|
|
label = stringResource(R.string.label_image_size),
|
|
value = currentImageSize,
|
|
onValueChange = onImageSizeChange,
|
|
valueRange = 0.5f..2.0f,
|
|
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
|
|
)
|
|
|
|
FormatSlider(
|
|
label = stringResource(R.string.label_horizontal_margin),
|
|
value = currentHorizontalMargin,
|
|
onValueChange = onHorizontalMarginChange,
|
|
valueRange = 0.0f..3.0f,
|
|
formatValue = {
|
|
when {
|
|
it <= 0.01f -> noneLabel
|
|
it in 0.99f..1.01f -> originalLabel
|
|
else -> "%.1fx".format(it)
|
|
}
|
|
}
|
|
)
|
|
|
|
FormatSlider(
|
|
label = stringResource(R.string.label_vertical_margin),
|
|
value = currentVerticalMargin,
|
|
onValueChange = onVerticalMarginChange,
|
|
valueRange = 0.0f..3.0f,
|
|
formatValue = {
|
|
when {
|
|
it <= 0.01f -> noneLabel
|
|
it in 0.99f..1.01f -> originalLabel
|
|
else -> "%.1fx".format(it)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun FontSelectionSheetContent(
|
|
currentFont: ReaderFont,
|
|
currentCustomFontPath: String?,
|
|
onFontSelected: (ReaderFont, String?) -> Unit,
|
|
customFonts: List<CustomFontEntity>,
|
|
onImportFont: (Uri) -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
var selectedTabIndex by remember { mutableIntStateOf(0) }
|
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
|
uri?.let { onImportFont(it) }
|
|
}
|
|
|
|
Column(modifier = Modifier.fillMaxWidth()) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(stringResource(R.string.select_font), style = MaterialTheme.typography.titleMedium)
|
|
IconButton(onClick = onDismiss) {
|
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
|
}
|
|
}
|
|
|
|
TabRow(selectedTabIndex = selectedTabIndex) {
|
|
Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }, text = { Text(stringResource(R.string.tab_presets)) })
|
|
Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }, text = { Text(stringResource(R.string.tab_imported)) })
|
|
}
|
|
|
|
Box(modifier = Modifier.heightIn(min = 200.dp, max = 400.dp)) {
|
|
when (selectedTabIndex) {
|
|
0 -> {
|
|
LazyColumn(contentPadding = PaddingValues(16.dp)) {
|
|
items(ReaderFont.entries.toTypedArray()) { font ->
|
|
val isSelected = currentCustomFontPath == null && currentFont == font
|
|
ListItem(
|
|
headlineContent = {
|
|
Text(font.displayName, fontFamily = getComposeFontFamily(font, null))
|
|
},
|
|
trailingContent = {
|
|
if (isSelected) Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_selected), tint = MaterialTheme.colorScheme.primary)
|
|
},
|
|
modifier = Modifier.clickable { onFontSelected(font, null) },
|
|
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
1 -> {
|
|
Column(modifier = Modifier.fillMaxSize()) {
|
|
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
|
Button(
|
|
onClick = { launcher.launch(arrayOf("font/ttf", "font/otf", "application/x-font-ttf")) },
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Icon(Icons.Default.Add, contentDescription = null)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(stringResource(R.string.button_import_from_files))
|
|
}
|
|
}
|
|
|
|
if (customFonts.isEmpty()) {
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
Text(
|
|
stringResource(R.string.no_imported_fonts_yet),
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(bottom = 32.dp)
|
|
)
|
|
}
|
|
} else {
|
|
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
|
items(customFonts) { fontEntity ->
|
|
val isSelected = currentCustomFontPath == fontEntity.path
|
|
val fontFamily = remember(fontEntity.path) {
|
|
try { FontFamily(Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
|
|
}
|
|
|
|
ListItem(
|
|
headlineContent = {
|
|
Text(fontEntity.displayName, fontFamily = fontFamily)
|
|
},
|
|
trailingContent = {
|
|
if (isSelected) {
|
|
Icon(
|
|
imageVector = Icons.Default.Check,
|
|
contentDescription = stringResource(R.string.content_desc_selected),
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
}
|
|
},
|
|
modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) },
|
|
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
|
|
)
|
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private const val REMOVE_EDGE_PADDING_KEY = "reader_remove_edge_padding"
|
|
|
|
fun saveRemoveEdgePadding(context: Context, enabled: Boolean) {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
prefs.edit { putBoolean(REMOVE_EDGE_PADDING_KEY, enabled) }
|
|
}
|
|
|
|
fun loadRemoveEdgePadding(context: Context): Boolean {
|
|
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
|
return prefs.getBoolean(REMOVE_EDGE_PADDING_KEY, false)
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun VisualOptionsSheet(
|
|
systemUiMode: SystemUiMode,
|
|
onSystemUiModeChange: (SystemUiMode) -> Unit,
|
|
pageInfoMode: PageInfoMode,
|
|
onPageInfoModeChange: (PageInfoMode) -> Unit,
|
|
pageInfoPosition: PageInfoPosition,
|
|
onPageInfoPositionChange: (PageInfoPosition) -> Unit,
|
|
pullToTurnEnabled: Boolean,
|
|
onPullToTurnChange: (Boolean) -> Unit,
|
|
pullToTurnMultiplier: Float,
|
|
onPullToTurnMultiplierChange: (Float) -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
ModalBottomSheet(
|
|
onDismissRequest = onDismiss,
|
|
sheetState = sheetState,
|
|
containerColor = MaterialTheme.colorScheme.surface,
|
|
contentWindowInsets = { WindowInsets.navigationBars }
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 24.dp, vertical = 8.dp)
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(stringResource(R.string.menu_visual_options), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
IconButton(onClick = onDismiss) {
|
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
// System UI
|
|
Text(stringResource(R.string.visual_options_system_ui), style = MaterialTheme.typography.titleMedium)
|
|
Text(stringResource(R.string.visual_options_system_ui_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
OptionSegmentedControl(
|
|
options = SystemUiMode.entries,
|
|
selectedOption = systemUiMode,
|
|
onOptionSelected = onSystemUiModeChange,
|
|
getLabel = { stringResource(it.titleRes) }
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
|
|
// Progress Bar
|
|
Text(stringResource(R.string.visual_options_progress_bar), style = MaterialTheme.typography.titleMedium)
|
|
Text(stringResource(R.string.visual_options_progress_bar_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
OptionSegmentedControl(
|
|
options = PageInfoMode.entries,
|
|
selectedOption = pageInfoMode,
|
|
onOptionSelected = onPageInfoModeChange,
|
|
getLabel = { stringResource(it.titleRes) }
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
Text(stringResource(R.string.visual_options_progress_bar_position), style = MaterialTheme.typography.titleSmall)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
OptionSegmentedControl(
|
|
options = PageInfoPosition.entries,
|
|
selectedOption = pageInfoPosition,
|
|
onOptionSelected = onPageInfoPositionChange,
|
|
getLabel = { stringResource(it.titleRes) }
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
|
|
// Pull to change chapter
|
|
Surface(
|
|
shape = RoundedCornerShape(12.dp),
|
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Column {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable { onPullToTurnChange(!pullToTurnEnabled) }
|
|
.padding(16.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(stringResource(R.string.visual_options_seamless_chapter), style = MaterialTheme.typography.titleMedium)
|
|
Text(stringResource(R.string.visual_options_seamless_chapter_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
Spacer(modifier = Modifier.width(16.dp))
|
|
Switch(checked = !pullToTurnEnabled, onCheckedChange = { onPullToTurnChange(!it) })
|
|
}
|
|
|
|
AnimatedVisibility(visible = pullToTurnEnabled) {
|
|
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) {
|
|
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f))
|
|
Text(stringResource(R.string.setting_pull_distance_change_chapter), style = MaterialTheme.typography.titleSmall)
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(stringResource(R.string.label_short), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
Slider(
|
|
value = pullToTurnMultiplier,
|
|
onValueChange = onPullToTurnMultiplierChange,
|
|
valueRange = 0.5f..2.0f,
|
|
steps = 14,
|
|
modifier = Modifier.weight(1f).padding(horizontal = 12.dp)
|
|
)
|
|
Text(stringResource(R.string.label_long), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(32.dp))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun <T> OptionSegmentedControl(
|
|
options: List<T>,
|
|
selectedOption: T,
|
|
onOptionSelected: (T) -> Unit,
|
|
getLabel: @Composable (T) -> String
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
|
|
.padding(4.dp),
|
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
) {
|
|
options.forEach { option ->
|
|
val isSelected = option == selectedOption
|
|
Box(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.clip(RoundedCornerShape(8.dp))
|
|
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
|
.clickable { onOptionSelected(option) }
|
|
.padding(vertical = 10.dp, horizontal = 4.dp),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Text(
|
|
text = getLabel(option),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
textAlign = TextAlign.Center
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun CustomCanvasSlider(
|
|
value: Float,
|
|
onValueChange: (Float) -> Unit,
|
|
valueRange: ClosedFloatingPointRange<Float>,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val fraction = ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f)
|
|
val activeColor = MaterialTheme.colorScheme.primary
|
|
val inactiveColor = MaterialTheme.colorScheme.surfaceVariant
|
|
val thumbColor = MaterialTheme.colorScheme.primary
|
|
|
|
Box(
|
|
modifier = modifier
|
|
.height(24.dp) // Keeps the touch target height slim
|
|
.pointerInput(valueRange) {
|
|
awaitEachGesture {
|
|
val down = awaitFirstDown()
|
|
fun update(offset: Offset) {
|
|
val newFraction = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
|
val rawValue = valueRange.start + newFraction * (valueRange.endInclusive - valueRange.start)
|
|
// Snap to 0.1 intervals for consistent formatting
|
|
onValueChange((rawValue * 10f).roundToInt() / 10f)
|
|
}
|
|
update(down.position)
|
|
drag(down.id) { change ->
|
|
change.consume()
|
|
update(change.position)
|
|
}
|
|
}
|
|
}
|
|
) {
|
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
val trackHeight = 4.dp.toPx()
|
|
val cornerRadius = CornerRadius(trackHeight / 2, trackHeight / 2)
|
|
val trackY = (size.height - trackHeight) / 2
|
|
|
|
// Draw Inactive Track
|
|
drawRoundRect(
|
|
color = inactiveColor,
|
|
topLeft = Offset(0f, trackY),
|
|
size = Size(size.width, trackHeight),
|
|
cornerRadius = cornerRadius
|
|
)
|
|
|
|
// Draw Active Track
|
|
val activeWidth = fraction * size.width
|
|
drawRoundRect(
|
|
color = activeColor,
|
|
topLeft = Offset(0f, trackY),
|
|
size = Size(activeWidth, trackHeight),
|
|
cornerRadius = cornerRadius
|
|
)
|
|
|
|
// Draw Thumb
|
|
val thumbRadius = 8.dp.toPx()
|
|
drawCircle(
|
|
color = thumbColor,
|
|
radius = thumbRadius,
|
|
center = Offset(
|
|
x = activeWidth.coerceIn(thumbRadius, size.width - thumbRadius),
|
|
y = size.height / 2
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun FormatSlider(
|
|
label: String,
|
|
value: Float,
|
|
onValueChange: (Float) -> Unit,
|
|
valueRange: ClosedFloatingPointRange<Float>,
|
|
stepSize: Float = 0.1f,
|
|
formatValue: (Float) -> String
|
|
) {
|
|
Column(modifier = Modifier.fillMaxWidth()) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
text = label,
|
|
style = MaterialTheme.typography.labelLarge,
|
|
fontWeight = FontWeight.SemiBold,
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
Text(
|
|
text = formatValue(value),
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
}
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
IconButton(
|
|
onClick = {
|
|
val newValue = (value - stepSize).coerceAtLeast(valueRange.start)
|
|
onValueChange((newValue * 10f).roundToInt() / 10f)
|
|
},
|
|
modifier = Modifier.size(32.dp) // Slimmer buttons
|
|
) {
|
|
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.content_desc_decrease), tint = MaterialTheme.colorScheme.primary)
|
|
}
|
|
|
|
// Using our new CustomCanvasSlider here!
|
|
CustomCanvasSlider(
|
|
value = value,
|
|
onValueChange = onValueChange,
|
|
valueRange = valueRange,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
|
|
IconButton(
|
|
onClick = {
|
|
val newValue = (value + stepSize).coerceAtMost(valueRange.endInclusive)
|
|
onValueChange((newValue * 10f).roundToInt() / 10f)
|
|
},
|
|
modifier = Modifier.size(32.dp) // Slimmer buttons
|
|
) {
|
|
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.content_desc_increase), tint = MaterialTheme.colorScheme.primary)
|
|
}
|
|
}
|
|
}
|
|
}
|