* Implemented ML-based comic panel detection and a panel popup viewer. * Optimized `ComicPanelDetector` for performance and memory efficiency. * Optimized comic panel detection performance by introducing a dedicated single-thread dispatcher for ML tasks, replacing mutex-based synchronization. * Refactored system UI handling and layout padding in `PdfViewerScreen`. * Refactor `PdfViewerScreen.kt` by extracting components and logic into specialized files. * replace hardcoded padding with dynamic header height and adjust IME layout logic * Improve `PdfTextBox` interaction and visual consistency during zoom and pan. * improve PDF text box dragging and scaling behavior across zoom levels * optimize color scheme calculation using remember and expand text dimming coverage * Implement in-app language selection and per-app language preferences. * Improve PDF lock stability in `PdfVerticalReader` * Implement "Preserve Image Colors" option for PDF themes * Optimize TOC locate in ReaderDrawer * Update library and home screen UI components * smoother page turn animation in epub pagination mode * Implement automatic page skipping for TTS when no text is found in PDF viewer * Refine status bar handling and window insets across main screens * Optimize ViewModel initialization and integrate AndroidX SplashScreen * Move ComicPanelDetector to debug source set and introduce IPanelDetector interface * Optimize TOC scrolling in PdfNavigationDrawerContent * Bump version to 1.0.45(45)
1049 lines
No EOL
40 KiB
Kotlin
1049 lines
No EOL
40 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
|
|
*/
|
|
// SharedComposables.kt
|
|
package com.aryan.reader
|
|
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.net.Uri
|
|
import androidx.activity.compose.ManagedActivityResultLauncher
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.browser.customtabs.CustomTabsIntent
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.slideInVertically
|
|
import androidx.compose.animation.slideOutVertically
|
|
import androidx.compose.foundation.BorderStroke
|
|
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.RowScope
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.heightIn
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.statusBarsPadding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.text.ClickableText
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
|
import androidx.compose.material.icons.filled.Add
|
|
import androidx.compose.material.icons.filled.Cloud
|
|
import androidx.compose.material.icons.filled.ContentCopy
|
|
import androidx.compose.material.icons.filled.Delete
|
|
import androidx.compose.material.icons.filled.Folder
|
|
import androidx.compose.material.icons.filled.Info
|
|
import androidx.compose.material.icons.filled.PushPin
|
|
import androidx.compose.material.icons.filled.SelectAll
|
|
import androidx.compose.material.icons.outlined.FileOpen
|
|
import androidx.compose.material.icons.outlined.Gavel
|
|
import androidx.compose.material.icons.outlined.Policy
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.FilledTonalButton
|
|
import androidx.compose.material3.HorizontalDivider
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.LocalTextStyle
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedButton
|
|
import androidx.compose.material3.OutlinedCard
|
|
import androidx.compose.material3.ProvideTextStyle
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
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.drawWithContent
|
|
import androidx.compose.ui.graphics.luminance
|
|
import androidx.compose.ui.platform.LocalClipboardManager
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalUriHandler
|
|
import androidx.compose.ui.platform.UriHandler
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.res.pluralStringResource
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.AnnotatedString
|
|
import androidx.compose.ui.text.SpanStyle
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.buildAnnotatedString
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.text.style.TextDecoration
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.graphics.vector.ImageVector
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.window.Dialog
|
|
import androidx.core.net.toUri
|
|
import com.aryan.reader.data.RecentFileItem
|
|
import timber.log.Timber
|
|
import java.text.SimpleDateFormat
|
|
import java.util.Date
|
|
import java.util.Locale
|
|
import kotlin.math.log10
|
|
import kotlin.math.pow
|
|
import kotlin.math.roundToInt
|
|
|
|
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
|
|
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
|
|
internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
|
|
|
|
class CustomTabUriHandler(private val context: Context) : UriHandler {
|
|
override fun openUri(uri: String) {
|
|
val customTabsIntent = CustomTabsIntent.Builder()
|
|
.setShowTitle(true)
|
|
.build()
|
|
try {
|
|
customTabsIntent.launchUrl(context, uri.toUri())
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Failed to launch Custom Tab, falling back to browser.")
|
|
val browserIntent = Intent(Intent.ACTION_VIEW, uri.toUri()).apply {
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(browserIntent)
|
|
}
|
|
}
|
|
}
|
|
|
|
fun formatFileSize(bytes: Long): String {
|
|
if (bytes <= 0) return "Unknown"
|
|
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
|
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
|
|
return String.format(Locale.US, "%.2f %s", bytes / 1024.0.pow(digitGroups.toDouble()), units[digitGroups])
|
|
}
|
|
|
|
@Composable
|
|
fun LegalText(
|
|
modifier: Modifier = Modifier,
|
|
prefixText: String,
|
|
textAlign: TextAlign = TextAlign.Center
|
|
) {
|
|
val uriHandler = LocalUriHandler.current
|
|
|
|
val fullAgreementText = stringResource(R.string.legal_agreement_full, prefixText, stringResource(R.string.legal_terms_of_service), stringResource(R.string.legal_privacy_policy))
|
|
val termsText = stringResource(R.string.legal_terms_of_service)
|
|
val privacyText = stringResource(R.string.legal_privacy_policy)
|
|
|
|
val annotatedString = buildAnnotatedString {
|
|
append(fullAgreementText)
|
|
|
|
val termsStartIndex = fullAgreementText.indexOf(termsText)
|
|
if (termsStartIndex >= 0) {
|
|
addStringAnnotation(tag = "terms", annotation = TERMS_URL, start = termsStartIndex, end = termsStartIndex + termsText.length)
|
|
addStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline), start = termsStartIndex, end = termsStartIndex + termsText.length)
|
|
}
|
|
|
|
val privacyStartIndex = fullAgreementText.indexOf(privacyText)
|
|
if (privacyStartIndex >= 0) {
|
|
addStringAnnotation(tag = "privacy", annotation = PRIVACY_POLICY_URL, start = privacyStartIndex, end = privacyStartIndex + privacyText.length)
|
|
addStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline), start = privacyStartIndex, end = privacyStartIndex + privacyText.length)
|
|
}
|
|
}
|
|
|
|
@Suppress("DEPRECATION")
|
|
ClickableText(
|
|
text = annotatedString,
|
|
style = MaterialTheme.typography.bodySmall.copy(
|
|
textAlign = textAlign,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
lineHeight = 18.sp
|
|
),
|
|
modifier = modifier,
|
|
onClick = { offset ->
|
|
annotatedString.getStringAnnotations(tag = "terms", start = offset, end = offset)
|
|
.firstOrNull()?.let { annotation ->
|
|
uriHandler.openUri(annotation.item)
|
|
}
|
|
annotatedString.getStringAnnotations(tag = "privacy", start = offset, end = offset)
|
|
.firstOrNull()?.let { annotation ->
|
|
uriHandler.openUri(annotation.item)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun rememberFilePickerLauncher(
|
|
onFilesSelected: (List<Uri>) -> Unit
|
|
): ManagedActivityResultLauncher<Array<String>, List<@JvmSuppressWildcards Uri>> {
|
|
return rememberLauncherForActivityResult(
|
|
contract = ActivityResultContracts.OpenMultipleDocuments(),
|
|
onResult = { uris: List<Uri> ->
|
|
if (uris.isNotEmpty()) {
|
|
Timber.d("${uris.size} file(s) selected.")
|
|
onFilesSelected(uris)
|
|
} else {
|
|
Timber.d("File selection cancelled.")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun ContextualTopAppBar(
|
|
selectedItemCount: Int,
|
|
onNavIconClick: () -> Unit,
|
|
onInfoClick: (() -> Unit)? = null,
|
|
onSelectAllClick: (() -> Unit)? = null,
|
|
onPinClick: (() -> Unit)? = null,
|
|
onDeleteClick: () -> Unit
|
|
) {
|
|
CustomTopAppBar(
|
|
title = { Text(stringResource(R.string.items_selected_count, selectedItemCount)) },
|
|
navigationIcon = {
|
|
IconButton(onClick = onNavIconClick) {
|
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.clear_selection))
|
|
}
|
|
},
|
|
actions = {
|
|
if (onPinClick != null) {
|
|
IconButton(onClick = onPinClick) {
|
|
Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.pin_unpin))
|
|
}
|
|
}
|
|
if (selectedItemCount == 1 && onInfoClick != null) {
|
|
IconButton(onClick = onInfoClick) {
|
|
Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.info))
|
|
}
|
|
}
|
|
if (onSelectAllClick != null) {
|
|
IconButton(onClick = onSelectAllClick) {
|
|
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.select_all))
|
|
}
|
|
}
|
|
IconButton(onClick = onDeleteClick) {
|
|
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun CustomTopAppBar(
|
|
modifier: Modifier = Modifier,
|
|
title: @Composable () -> Unit,
|
|
navigationIcon: @Composable () -> Unit = {},
|
|
actions: @Composable RowScope.() -> Unit = {}
|
|
) {
|
|
Surface(
|
|
modifier = modifier.fillMaxWidth(),
|
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
shadowElevation = 2.dp
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.statusBarsPadding()
|
|
.height(56.dp)
|
|
.padding(horizontal = 4.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
navigationIcon()
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.padding(start = 12.dp),
|
|
contentAlignment = Alignment.CenterStart
|
|
) {
|
|
ProvideTextStyle(value = MaterialTheme.typography.titleLarge) {
|
|
title()
|
|
}
|
|
}
|
|
Row(
|
|
horizontalArrangement = Arrangement.End,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
actions()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun DeleteConfirmationDialog(
|
|
count: Int,
|
|
onConfirm: () -> Unit,
|
|
onDismiss: () -> Unit,
|
|
isPermanentDelete: Boolean = false,
|
|
containsFolderItems: Boolean = false
|
|
) {
|
|
val title = if (isPermanentDelete) pluralStringResource(R.plurals.dialog_delete_permanently, count) else stringResource(R.string.dialog_remove_from_recents)
|
|
|
|
val text = if (isPermanentDelete) {
|
|
if (containsFolderItems) {
|
|
stringResource(R.string.dialog_warning_folder_sync_delete)
|
|
} else {
|
|
pluralStringResource(R.plurals.dialog_permanently_delete_desc, count, count)
|
|
}
|
|
} else {
|
|
pluralStringResource(R.plurals.dialog_remove_recents_desc, count, count)
|
|
}
|
|
|
|
val confirmText = if (isPermanentDelete) stringResource(R.string.action_delete) else stringResource(R.string.action_remove)
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text(title) },
|
|
text = {
|
|
Text(
|
|
text,
|
|
color = if (containsFolderItems && isPermanentDelete) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = onConfirm,
|
|
colors = if (containsFolderItems && isPermanentDelete) ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) else ButtonDefaults.textButtonColors()
|
|
) {
|
|
Text(confirmText)
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit) {
|
|
LocalContext.current
|
|
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
|
|
|
val originalName = item.title ?: item.displayName
|
|
var editingName by remember { mutableStateOf(item.customName ?: originalName) }
|
|
val hasCustomName = item.customName != null
|
|
|
|
val formattedDate = remember(item.timestamp) {
|
|
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp))
|
|
}
|
|
|
|
val context = LocalContext.current
|
|
val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
|
|
val pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) {
|
|
if (isOpdsStream) {
|
|
"Source: OPDS Stream"
|
|
} else if (item.sourceFolderUri != null && item.uriString != null) {
|
|
try {
|
|
val uri = item.uriString.toUri()
|
|
val docId = if (android.provider.DocumentsContract.isDocumentUri(context, uri)) {
|
|
android.provider.DocumentsContract.getDocumentId(uri)
|
|
} else if (android.provider.DocumentsContract.isTreeUri(uri)) {
|
|
android.provider.DocumentsContract.getTreeDocumentId(uri)
|
|
} else {
|
|
Uri.decode(uri.toString())
|
|
}
|
|
|
|
val split = docId.split(":")
|
|
val storageName = if (split[0].equals("primary", ignoreCase = true)) "Internal storage" else split[0]
|
|
var relativePath = if (split.size > 1) {
|
|
Uri.decode(split[1]).removeSuffix("/")
|
|
} else ""
|
|
|
|
if (!relativePath.endsWith(item.displayName)) {
|
|
relativePath = if (relativePath.isEmpty()) item.displayName else "$relativePath/${item.displayName}"
|
|
}
|
|
|
|
val leadingSlash = if (relativePath.isNotEmpty() && !relativePath.startsWith("/")) "/" else ""
|
|
|
|
"/$storageName$leadingSlash$relativePath"
|
|
} catch (_: Exception) {
|
|
val decoded = Uri.decode(item.uriString)
|
|
if (decoded.contains("primary:")) {
|
|
"/Internal storage/${decoded.substringAfter("primary:").substringBeforeLast("/")}/${item.displayName}"
|
|
} else {
|
|
item.displayName
|
|
}
|
|
}
|
|
} else {
|
|
"In-App Storage"
|
|
}
|
|
}
|
|
|
|
Dialog(onDismissRequest = onDismiss) {
|
|
Surface(
|
|
shape = MaterialTheme.shapes.extraLarge,
|
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(24.dp),
|
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
) {
|
|
Text(
|
|
stringResource(R.string.file_information),
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
|
|
androidx.compose.material3.OutlinedTextField(
|
|
value = editingName,
|
|
onValueChange = { editingName = it },
|
|
label = { Text(stringResource(R.string.book_name)) },
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.heightIn(min = 64.dp, max = 130.dp),
|
|
maxLines = 4,
|
|
textStyle = MaterialTheme.typography.bodyLarge,
|
|
trailingIcon = {
|
|
IconButton(onClick = {
|
|
clipboardManager.setText(AnnotatedString(editingName))
|
|
}) {
|
|
Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.copy_name), modifier = Modifier.size(20.dp))
|
|
}
|
|
}
|
|
)
|
|
|
|
if (hasCustomName) {
|
|
Text(
|
|
text = stringResource(R.string.original_name, originalName),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(top = 2.dp),
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
TextButton(
|
|
onClick = {
|
|
editingName = originalName
|
|
onUpdateName(null)
|
|
},
|
|
modifier = Modifier.align(Alignment.End),
|
|
contentPadding = PaddingValues(0.dp)
|
|
) {
|
|
Text(stringResource(R.string.revert_to_original))
|
|
}
|
|
} else if (originalName != item.displayName) {
|
|
Text(
|
|
text = stringResource(R.string.file_name, item.displayName),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 2,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
|
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
|
|
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
|
InfoRowDetailed(stringResource(R.string.author), it)
|
|
}
|
|
InfoRowDetailed(stringResource(R.string.format), item.type.name)
|
|
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
|
|
InfoRowDetailed(stringResource(R.string.added), formattedDate)
|
|
|
|
val pathTextFinal = if (isOpdsStream) {
|
|
stringResource(R.string.source_opds)
|
|
} else if (pathText == "In-App Storage") {
|
|
stringResource(R.string.source_in_app)
|
|
} else {
|
|
pathText.replace("Internal storage", stringResource(R.string.internal_storage))
|
|
}
|
|
|
|
InfoRowDetailed(
|
|
label = stringResource(R.string.location),
|
|
value = pathTextFinal,
|
|
maxLines = 4,
|
|
isScrollable = true,
|
|
onCopy = {
|
|
clipboardManager.setText(AnnotatedString(pathTextFinal))
|
|
}
|
|
)
|
|
}
|
|
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 8.dp),
|
|
horizontalArrangement = Arrangement.End
|
|
) {
|
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
androidx.compose.material3.Button(onClick = {
|
|
val finalName = editingName.trim()
|
|
if (finalName != (item.customName ?: originalName)) {
|
|
if (finalName == originalName || finalName.isEmpty()) {
|
|
onUpdateName(null)
|
|
} else {
|
|
onUpdateName(finalName)
|
|
}
|
|
}
|
|
onDismiss()
|
|
}) { Text(stringResource(R.string.action_save)) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun InfoRowDetailed(
|
|
label: String,
|
|
value: String,
|
|
maxLines: Int = 1,
|
|
isScrollable: Boolean = false,
|
|
onCopy: (() -> Unit)? = null
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.Top
|
|
) {
|
|
Text(
|
|
text = label,
|
|
fontWeight = FontWeight.SemiBold,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier
|
|
.width(85.dp)
|
|
.padding(top = 2.dp)
|
|
)
|
|
|
|
val scrollModifier = if (isScrollable) {
|
|
Modifier
|
|
.heightIn(max = 66.dp)
|
|
.verticalScroll(rememberScrollState())
|
|
} else Modifier
|
|
|
|
Text(
|
|
text = value,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
maxLines = if (isScrollable) Int.MAX_VALUE else maxLines,
|
|
overflow = if (isScrollable) TextOverflow.Clip else TextOverflow.Ellipsis,
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.padding(top = 2.dp)
|
|
.then(scrollModifier)
|
|
)
|
|
if (onCopy != null) {
|
|
IconButton(
|
|
onClick = onCopy,
|
|
modifier = Modifier
|
|
.size(24.dp)
|
|
.padding(start = 4.dp)
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.ContentCopy,
|
|
contentDescription = "Copy $label",
|
|
modifier = Modifier.size(16.dp),
|
|
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun CustomTopBanner(bannerMessage: BannerMessage?) {
|
|
AnimatedVisibility(
|
|
visible = bannerMessage != null,
|
|
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
|
|
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.statusBarsPadding(),
|
|
contentAlignment = Alignment.TopCenter
|
|
) {
|
|
Surface(
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer,
|
|
shape = MaterialTheme.shapes.medium,
|
|
shadowElevation = 8.dp
|
|
) {
|
|
Text(
|
|
text = bannerMessage?.message ?: "",
|
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontWeight = FontWeight.SemiBold
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("KotlinConstantConditions")
|
|
@Composable
|
|
fun AboutDialog(onDismiss: () -> Unit) {
|
|
val uriHandler = LocalUriHandler.current
|
|
val isOss = BuildConfig.FLAVOR == "oss"
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
shape = RoundedCornerShape(24.dp),
|
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
title = {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
modifier = Modifier.fillMaxWidth()
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.about_app_name),
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
Spacer(modifier = Modifier.height(2.dp))
|
|
Text(
|
|
text = if (isOss) stringResource(R.string.about_oss_version) else stringResource(R.string.about_play_version),
|
|
style = MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.primary
|
|
)
|
|
}
|
|
},
|
|
text = {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.verticalScroll(rememberScrollState()),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.about_version_name, BuildConfig.VERSION_NAME),
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
fontWeight = FontWeight.SemiBold
|
|
)
|
|
Text(
|
|
text = stringResource(R.string.about_build_code, BuildConfig.VERSION_CODE.toString()),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(20.dp))
|
|
|
|
if (isOss) {
|
|
AboutInfoRow(
|
|
icon = {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.github),
|
|
contentDescription = stringResource(R.string.about_github),
|
|
modifier = Modifier.size(22.dp),
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
},
|
|
text = stringResource(R.string.about_github),
|
|
subtitle = stringResource(R.string.about_github_desc),
|
|
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
|
|
)
|
|
} else {
|
|
AboutInfoRow(
|
|
icon = {
|
|
Icon(
|
|
imageVector = Icons.Outlined.Policy,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(22.dp),
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
},
|
|
text = stringResource(R.string.legal_privacy_policy),
|
|
subtitle = stringResource(R.string.about_privacy_desc),
|
|
onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(10.dp))
|
|
|
|
AboutInfoRow(
|
|
icon = {
|
|
Icon(
|
|
imageVector = Icons.Outlined.Gavel,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(22.dp),
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
},
|
|
text = stringResource(R.string.legal_terms_of_service),
|
|
subtitle = stringResource(R.string.about_terms_desc),
|
|
onClick = { uriHandler.openUri(TERMS_URL) }
|
|
)
|
|
|
|
Spacer(modifier = Modifier.height(10.dp))
|
|
|
|
AboutInfoRow(
|
|
icon = {
|
|
Icon(
|
|
imageVector = Icons.Outlined.FileOpen,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(22.dp),
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
},
|
|
text = stringResource(R.string.legal_licenses),
|
|
subtitle = stringResource(R.string.about_licenses_desc),
|
|
onClick = { uriHandler.openUri(LICENSES_URL) }
|
|
)
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = onDismiss,
|
|
shape = RoundedCornerShape(50),
|
|
modifier = Modifier.padding(horizontal = 8.dp)
|
|
) {
|
|
Text(stringResource(R.string.action_close), fontWeight = FontWeight.Medium)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun AboutInfoRow(
|
|
icon: @Composable () -> Unit,
|
|
text: String,
|
|
subtitle: String? = null,
|
|
onClick: () -> Unit
|
|
) {
|
|
OutlinedCard(
|
|
onClick = onClick,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(12.dp)
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
icon()
|
|
Spacer(modifier = Modifier.width(14.dp))
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(
|
|
text = text,
|
|
style = MaterialTheme.typography.bodyLarge,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
if (subtitle != null) {
|
|
Spacer(modifier = Modifier.height(2.dp))
|
|
Text(
|
|
text = subtitle,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.width(4.dp))
|
|
Icon(
|
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(18.dp),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun EmptyState(
|
|
title: String,
|
|
message: String,
|
|
onSelectFileClick: () -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
primaryButtonText: String = stringResource(R.string.empty_select_file),
|
|
secondaryButtonText: String? = null,
|
|
onSecondaryClick: (() -> Unit)? = null
|
|
) {
|
|
Column(
|
|
modifier = modifier
|
|
.fillMaxSize()
|
|
.padding(horizontal = 32.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.Center
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Outlined.FileOpen,
|
|
contentDescription = "No files icon",
|
|
modifier = Modifier.size(80.dp),
|
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
Text(
|
|
text = title,
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
color = MaterialTheme.colorScheme.onSurface,
|
|
textAlign = TextAlign.Center
|
|
)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Text(
|
|
text = message,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
textAlign = TextAlign.Center,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
Spacer(modifier = Modifier.height(32.dp))
|
|
|
|
SelectFileButton(onClick = onSelectFileClick, text = primaryButtonText)
|
|
|
|
if (secondaryButtonText != null && onSecondaryClick != null) {
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
OutlinedButton(onClick = onSecondaryClick) {
|
|
Text(secondaryButtonText)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun SelectFileButton(onClick: () -> Unit, text: String) {
|
|
FilledTonalButton(
|
|
onClick = onClick,
|
|
shape = MaterialTheme.shapes.medium
|
|
) {
|
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize))
|
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
Text(text)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun ClearCloudDataConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text(stringResource(R.string.clear_cloud_data_title)) },
|
|
text = { Text(stringResource(R.string.clear_cloud_data_desc)) },
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = onConfirm,
|
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
|
) { Text(stringResource(R.string.delete_all_data)) }
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun AutoSizeText(
|
|
text: String,
|
|
modifier: Modifier = Modifier,
|
|
style: TextStyle = LocalTextStyle.current,
|
|
maxLines: Int = 1,
|
|
) {
|
|
var scaledTextStyle by remember(text, style) { mutableStateOf(style) }
|
|
var readyToDraw by remember(text, style) { mutableStateOf(false) }
|
|
|
|
Text(
|
|
text = text,
|
|
modifier = modifier.drawWithContent {
|
|
if (readyToDraw) {
|
|
drawContent()
|
|
}
|
|
},
|
|
style = scaledTextStyle,
|
|
maxLines = maxLines,
|
|
softWrap = false,
|
|
onTextLayout = { textLayoutResult ->
|
|
if (textLayoutResult.hasVisualOverflow) {
|
|
scaledTextStyle = scaledTextStyle.copy(
|
|
fontSize = scaledTextStyle.fontSize * 0.95
|
|
)
|
|
} else {
|
|
readyToDraw = true
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolean = false) {
|
|
val containerColor = if (overlay) Color.Black.copy(alpha = 0.6f) else MaterialTheme.colorScheme.secondaryContainer
|
|
val contentColor = if (overlay) Color.White else MaterialTheme.colorScheme.onSecondaryContainer
|
|
|
|
Surface(
|
|
modifier = modifier,
|
|
shape = RoundedCornerShape(50),
|
|
color = containerColor,
|
|
contentColor = contentColor,
|
|
border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) else null
|
|
) {
|
|
Text(
|
|
text = type.name.uppercase(),
|
|
style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.sp),
|
|
fontWeight = FontWeight.ExtraBold,
|
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
private const val UNKNOWN_AUTHOR_LABEL = "No author listed"
|
|
|
|
fun RecentFileItem.cardTitle(): String {
|
|
return customName ?: title?.takeIf { it.isNotBlank() } ?: displayName
|
|
}
|
|
|
|
fun RecentFileItem.cardAuthor(): String {
|
|
return author
|
|
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
|
?: UNKNOWN_AUTHOR_LABEL
|
|
}
|
|
|
|
fun RecentFileItem.progressPercentValue(): Int {
|
|
return (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt()
|
|
}
|
|
|
|
fun RecentFileItem.progressFraction(): Float {
|
|
return progressPercentValue() / 100f
|
|
}
|
|
|
|
fun RecentFileItem.isOpdsStream(): Boolean {
|
|
return uriString?.startsWith("opds-pse://") == true
|
|
}
|
|
|
|
@Composable
|
|
private fun statusBadgeColors(overlay: Boolean): Pair<Color, Color> {
|
|
val container = if (overlay) {
|
|
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.92f)
|
|
} else {
|
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
|
}
|
|
val content = if (overlay) {
|
|
MaterialTheme.colorScheme.onSurface
|
|
} else {
|
|
MaterialTheme.colorScheme.onSurfaceVariant
|
|
}
|
|
return container to content
|
|
}
|
|
|
|
@Composable
|
|
fun StatusIconBadge(
|
|
icon: ImageVector,
|
|
contentDescription: String,
|
|
modifier: Modifier = Modifier,
|
|
overlay: Boolean = false,
|
|
) {
|
|
val (containerColor, contentColor) = statusBadgeColors(overlay)
|
|
|
|
Surface(
|
|
modifier = modifier,
|
|
shape = RoundedCornerShape(12.dp),
|
|
color = containerColor,
|
|
contentColor = contentColor,
|
|
tonalElevation = if (overlay) 0.dp else 2.dp,
|
|
shadowElevation = if (overlay) 0.dp else 1.dp,
|
|
border = if (overlay) BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) else null
|
|
) {
|
|
Icon(
|
|
imageVector = icon,
|
|
contentDescription = contentDescription,
|
|
modifier = Modifier.padding(6.dp).size(14.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun FileStatusBadges(
|
|
item: RecentFileItem,
|
|
isPinned: Boolean,
|
|
modifier: Modifier = Modifier,
|
|
overlay: Boolean = false,
|
|
) {
|
|
Row(
|
|
modifier = modifier,
|
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
if (item.sourceFolderUri != null) {
|
|
StatusIconBadge(
|
|
icon = Icons.Default.Folder,
|
|
contentDescription = stringResource(R.string.local_folder),
|
|
overlay = overlay
|
|
)
|
|
}
|
|
if (item.isOpdsStream()) {
|
|
StatusIconBadge(
|
|
icon = Icons.Default.Cloud,
|
|
contentDescription = stringResource(R.string.opds_stream),
|
|
overlay = overlay
|
|
)
|
|
}
|
|
if (isPinned) {
|
|
StatusIconBadge(
|
|
icon = Icons.Default.PushPin,
|
|
contentDescription = stringResource(R.string.pinned),
|
|
overlay = overlay
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun ReadingProgressSection(
|
|
progressPercentage: Float?,
|
|
modifier: Modifier = Modifier,
|
|
label: String? = null,
|
|
compact: Boolean = false,
|
|
) {
|
|
val percent = (progressPercentage ?: 0f).coerceIn(0f, 100f).roundToInt()
|
|
val progress = percent / 100f
|
|
|
|
Column(modifier = modifier) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
if (label != null) {
|
|
Text(
|
|
text = label,
|
|
style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.weight(1f))
|
|
Surface(
|
|
shape = RoundedCornerShape(50),
|
|
color = MaterialTheme.colorScheme.primaryContainer,
|
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
|
) {
|
|
Text(
|
|
text = "$percent%",
|
|
style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
|
|
fontWeight = FontWeight.Bold,
|
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
|
)
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(if (compact) 6.dp else 8.dp))
|
|
LinearProgressIndicator(
|
|
progress = { progress },
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(if (compact) 5.dp else 6.dp),
|
|
color = MaterialTheme.colorScheme.primary,
|
|
trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
|
)
|
|
}
|
|
} |