App themes (#217)

* Updated `.gitattributes` to correctly vendor all files within the `libmobi` and `woff2` directories.

* Improved TTS chapter transition handling

* Improved TTS stability and chapter advancement in the EPUB reader.

* Implemented PDF metadata extraction for titles and authors using PdfiumCore in `MainViewModel` and `MetadataExtractionWorker`.

* Implemented support for interactive footnotes in the EPUB vertical reader.

* Added support for footnotes in the paginated EPUB reader.

* Implemented a customizable app theming system.

* Added support for app-wide contrast and text brightness customization in the "App Theme" option.

* option to adjust pull-to-chapter-change drag in the EPUB vertical reader under visual options.

* Updated `PdfViewerScreen` to replace the standalone full-screen toggle with a more comprehensive "Visual Options" system UI management tool.
This commit is contained in:
Aryan 2026-04-21 18:11:55 +05:30 committed by GitHub
parent 4cb25e6abd
commit 71e3614ad6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1413 additions and 287 deletions

View file

@ -34,6 +34,10 @@ import androidx.compose.material3.Switch
import androidx.compose.material.icons.filled.Settings
import androidx.compose.foundation.rememberScrollState
import android.graphics.Bitmap
import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.epubreader.loadSystemUiMode
import com.aryan.reader.epubreader.saveSystemUiMode
import com.aryan.reader.epubreader.OptionSegmentedControl
import android.graphics.RectF
import android.net.Uri
import android.os.Build
@ -127,8 +131,6 @@ import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
@ -384,6 +386,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
DICTIONARY("External Apps", "Top Bar"),
THEME("Theme Settings", "Top Bar"),
LOCK_PANNING("Lock Panning", "Top Bar"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
FULL_SCREEN("Full Screen", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
@ -520,16 +523,6 @@ private fun loadPdfScrollLocked(context: Context, bookId: String): Boolean {
return prefs.getBoolean(PDF_SCROLL_LOCKED_PREFIX + bookId, false)
}
private fun savePdfFullScreen(context: Context, bookId: String, isFull: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_FULL_SCREEN_PREFIX + bookId, isFull) }
}
private fun loadPdfFullScreen(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_FULL_SCREEN_PREFIX + bookId, false)
}
private fun savePdfAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PDF_AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) }
@ -1238,6 +1231,8 @@ fun PdfViewerScreen(
val isPdfDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
var pageAspectRatios by remember { mutableStateOf<List<Float>>(emptyList()) }
var showBars by rememberSaveable { mutableStateOf(true) }
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) }
var isFullScreen by remember { mutableStateOf(false) }
var documentPassword by rememberSaveable { mutableStateOf<String?>(null) }
var pendingRestorePage by rememberSaveable { mutableStateOf(initialPage) }
@ -1311,7 +1306,6 @@ fun PdfViewerScreen(
LaunchedEffect(bookId) {
isScrollLocked = loadPdfScrollLocked(context, bookId)
isFullScreen = loadPdfFullScreen(context, bookId)
lockedState = loadPdfLockedState(context, bookId)
}
@ -1529,42 +1523,84 @@ fun PdfViewerScreen(
}
val window = (view.context as? Activity)?.window
LaunchedEffect(isFullScreen) {
LaunchedEffect(systemUiMode, showBars) {
if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view)
if (isFullScreen) {
showBars = false
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
showBars = true
insetsController.show(WindowInsetsCompat.Type.systemBars())
when (systemUiMode) {
SystemUiMode.DEFAULT -> {
insetsController.show(WindowInsetsCompat.Type.systemBars())
}
SystemUiMode.SYNC -> {
if (showBars) {
insetsController.show(WindowInsetsCompat.Type.systemBars())
} else {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
SystemUiMode.HIDDEN -> {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
}
val dockHeight = 64.dp
val dockHeightPx = with(LocalDensity.current) { dockHeight.toPx() }
val density = LocalDensity.current
val verticalHeaderHeight by remember(
val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() }
val dummySearcher: suspend (String) -> List<SearchResult> = { emptyList() }
val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher)
val showStandardBars = showBars && !isEditMode
val snackbarPadding by animateDpAsState(
targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp,
label = "SnackbarPadding"
)
val targetVerticalHeaderHeight = remember(
dockLocation,
snapPreviewLocation,
isEditMode,
isDockDragging
isDockDragging,
showStandardBars,
systemUiMode,
showBars,
statusBarHeightDp
) {
derivedStateOf {
if (!isEditMode) {
0.dp
} else {
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
if (isStickyTop || isPreviewingTop) dockHeight else 0.dp
if (!isEditMode) {
var h = 0.dp
if (showStandardBars) {
h += 56.dp
}
val isStatusBarVisible = when (systemUiMode) {
SystemUiMode.DEFAULT -> true
SystemUiMode.SYNC -> showBars
SystemUiMode.HIDDEN -> false
}
if (isStatusBarVisible) {
h += statusBarHeightDp
}
h
} else {
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
if (isStickyTop || isPreviewingTop) dockHeight else 0.dp
}
}
val verticalHeaderHeight by animateDpAsState(
targetValue = targetVerticalHeaderHeight,
animationSpec = tween(durationMillis = 200),
label = "verticalHeaderHeight"
)
val verticalFooterHeight by remember(
dockLocation,
snapPreviewLocation,
@ -2266,7 +2302,7 @@ fun PdfViewerScreen(
}
selectedTextBoxId = null
} else {
if (!isFullScreen && !(isMusicianMode && isAutoScrollModeActive)) {
if (!(isMusicianMode && isAutoScrollModeActive)) {
showBars = !showBars
Timber.d("Vertical Reader Clicked. showBars now: $showBars")
}
@ -3702,14 +3738,9 @@ fun PdfViewerScreen(
var activeQuery by remember { mutableStateOf("") }
val dummySearcher: suspend (String) -> List<SearchResult> = { emptyList() }
val searchState = rememberSearchState(scope = coroutineScope, searcher = dummySearcher)
var smartSearchResult by remember { mutableStateOf<SmartSearchResult?>(null) }
var currentPdfSearchResult by remember { mutableStateOf<SearchResult?>(null) }
val density = LocalDensity.current
val navBarHeight = WindowInsets.systemBars.getBottom(density)
val imeHeight = WindowInsets.ime.getBottom(density)
@ -3733,16 +3764,7 @@ fun PdfViewerScreen(
}
}
val statusBarHeight = WindowInsets.statusBars.getTop(density)
val topBarHeightPx = with(density) { 56.dp.toPx() }
val topScrollLimitPx = remember(showBars, statusBarHeight) {
if (showBars) {
statusBarHeight + topBarHeightPx
} else {
0f
}
}
val topScrollLimitPx = with(density) { verticalHeaderHeight.toPx() }
LaunchedEffect(searchState.searchQuery, currentBookId) {
val query = searchState.searchQuery
@ -3902,10 +3924,7 @@ fun PdfViewerScreen(
onNavigateBack()
}
isFullScreen -> {
isFullScreen = false
savePdfFullScreen(context, bookId, false)
}
showVisualOptionsSheet -> showVisualOptionsSheet = false
showReindexDialog != null -> showReindexDialog = null
@ -3950,12 +3969,6 @@ fun PdfViewerScreen(
}
}
val showStandardBars = showBars && !isEditMode
val snackbarPadding by animateDpAsState(
targetValue = if (showStandardBars && !searchState.isSearchActive) 56.dp else 0.dp,
label = "SnackbarPadding"
)
ModalNavigationDrawer(
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
@ -4487,10 +4500,8 @@ fun PdfViewerScreen(
)
}
) { paddingValues ->
BoxWithConstraints(modifier = Modifier
.fillMaxSize()
.padding(paddingValues)) {
IntSize(constraints.maxWidth, constraints.maxHeight)
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(bottom = paddingValues.calculateBottomPadding())) {
IntSize(constraints.maxWidth, constraints.maxHeight)
val boxConstraints = constraints
val boxMaxWidthFloat = boxConstraints.maxWidth.toFloat()
val boxMaxHeightFloat = boxConstraints.maxHeight.toFloat()
@ -5755,22 +5766,6 @@ fun PdfViewerScreen(
}
}
if (!hiddenTools.contains(PdfReaderTool.FULL_SCREEN.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_fullscreen),
description = stringResource(R.string.tooltip_fullscreen_desc),
onClick = {
isFullScreen = true
savePdfFullScreen(context, bookId, true)
}) {
Icon(
imageVector = Icons.Default.Fullscreen,
contentDescription = "Enter Full Screen",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (!hiddenTools.contains(PdfReaderTool.DICTIONARY.name)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_dictionary),
@ -5877,6 +5872,24 @@ fun PdfViewerScreen(
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.VISUAL_OPTIONS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_visual_options)) },
onClick = {
showMoreMenu = false
showVisualOptionsSheet = true
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.READING_MODE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
@ -6612,42 +6625,6 @@ fun PdfViewerScreen(
}
}
// Full Screen Exit Button
AnimatedVisibility(
visible = isFullScreen,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(24.dp)
) {
val isBackgroundDark = displayMode == DisplayMode.PAGINATION || isPdfDarkMode
val fabContainerColor = if (isBackgroundDark) Color.White.copy(alpha = 0.25f)
else Color.Black.copy(alpha = 0.25f)
val fabContentColor = if (isBackgroundDark) Color.White else Color.Black
Surface(
onClick = {
isFullScreen = false
savePdfFullScreen(context, bookId, false)
},
color = fabContainerColor,
contentColor = fabContentColor,
shape = CircleShape,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.FullscreenExit,
contentDescription = "Exit Full Screen",
modifier = Modifier.size(26.dp)
)
}
}
}
if (isEditMode) {
val density = LocalDensity.current
@ -7994,6 +7971,16 @@ fun PdfViewerScreen(
}
)
}
if (showVisualOptionsSheet) {
PdfVisualOptionsSheet(
systemUiMode = systemUiMode,
onSystemUiModeChange = { mode ->
systemUiMode = mode
saveSystemUiMode(context, mode)
},
onDismiss = { showVisualOptionsSheet = false }
)
}
if (showCustomizeToolsSheet) {
PdfCustomizeToolsSheet(
hiddenTools = hiddenTools,
@ -8715,4 +8702,50 @@ fun PdfCustomizeToolsSheet(
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PdfVisualOptionsSheet(
systemUiMode: SystemUiMode,
onSystemUiModeChange: (SystemUiMode) -> 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)
.padding(bottom = 32.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))
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 = { it.title }
)
}
}
}