/*
* 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 .
*
* 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,
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 OptionSegmentedControl(
options: List,
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,
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,
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)
}
}
}
}