528 lines
No EOL
19 KiB
Kotlin
528 lines
No EOL
19 KiB
Kotlin
/*
|
|
* Episteme Reader - A native Android document reader.
|
|
* Copyright (C) 2026 Episteme Authors
|
|
*
|
|
* 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/>.
|
|
*
|
|
* Electronic mail: epistemereader@gmail.com
|
|
*/
|
|
package com.aryan.reader
|
|
|
|
import android.net.Uri
|
|
import timber.log.Timber
|
|
import androidx.activity.compose.ManagedActivityResultLauncher
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.material3.LocalTextStyle
|
|
import androidx.compose.ui.draw.drawWithContent
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.slideInVertically
|
|
import androidx.compose.animation.slideOutVertically
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
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.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.statusBarsPadding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
import androidx.compose.material.icons.filled.Add
|
|
import androidx.compose.material.icons.filled.Delete
|
|
import androidx.compose.material.icons.filled.Info
|
|
import androidx.compose.material.icons.outlined.FileOpen
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.FilledTonalButton
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.MaterialTheme
|
|
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.remember
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
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 com.aryan.reader.data.RecentFileItem
|
|
import java.text.SimpleDateFormat
|
|
import java.util.Date
|
|
import java.util.Locale
|
|
import androidx.compose.foundation.text.ClickableText
|
|
import androidx.compose.ui.platform.LocalUriHandler
|
|
import androidx.compose.ui.text.SpanStyle
|
|
import androidx.compose.ui.text.buildAnnotatedString
|
|
import androidx.compose.ui.text.style.TextDecoration
|
|
import androidx.compose.ui.text.withStyle
|
|
import androidx.compose.ui.unit.sp
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import androidx.browser.customtabs.CustomTabsIntent
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.material.icons.filled.SelectAll
|
|
import androidx.compose.material3.HorizontalDivider
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.platform.UriHandler
|
|
import androidx.core.net.toUri
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun LegalText(
|
|
modifier: Modifier = Modifier,
|
|
prefixText: String, // Changed from baseText
|
|
textAlign: TextAlign = TextAlign.Center
|
|
) {
|
|
val uriHandler = LocalUriHandler.current
|
|
val annotatedString = buildAnnotatedString {
|
|
append("$prefixText you agree to our ")
|
|
pushStringAnnotation(tag = "terms", annotation = TERMS_URL)
|
|
withStyle(
|
|
style = SpanStyle(
|
|
color = MaterialTheme.colorScheme.primary,
|
|
textDecoration = TextDecoration.Underline
|
|
)
|
|
) {
|
|
append("Terms of Service")
|
|
}
|
|
pop()
|
|
append(" and acknowledge you have read our ")
|
|
pushStringAnnotation(tag = "privacy", annotation = PRIVACY_POLICY_URL)
|
|
withStyle(
|
|
style = SpanStyle(
|
|
color = MaterialTheme.colorScheme.primary,
|
|
textDecoration = TextDecoration.Underline
|
|
)
|
|
) {
|
|
append("Privacy Policy")
|
|
}
|
|
pop()
|
|
append(".")
|
|
}
|
|
|
|
@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,
|
|
onDeleteClick: () -> Unit
|
|
) {
|
|
CustomTopAppBar(
|
|
title = { Text("$selectedItemCount selected") },
|
|
navigationIcon = {
|
|
IconButton(onClick = onNavIconClick) {
|
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Clear Selection")
|
|
}
|
|
},
|
|
actions = {
|
|
if (selectedItemCount == 1 && onInfoClick != null) {
|
|
IconButton(onClick = onInfoClick) {
|
|
Icon(Icons.Filled.Info, contentDescription = "Info")
|
|
}
|
|
}
|
|
if (onSelectAllClick != null) {
|
|
IconButton(onClick = onSelectAllClick) {
|
|
Icon(Icons.Filled.SelectAll, contentDescription = "Select All")
|
|
}
|
|
}
|
|
IconButton(onClick = onDeleteClick) {
|
|
Icon(Icons.Filled.Delete, contentDescription = "Delete")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun CustomTopAppBar(
|
|
modifier: Modifier = Modifier,
|
|
title: @Composable () -> Unit,
|
|
navigationIcon: @Composable () -> Unit = {},
|
|
actions: @Composable RowScope.() -> Unit = {}
|
|
) {
|
|
Surface(
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.height(56.dp),
|
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
shadowElevation = 2.dp
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.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) {
|
|
val title = if (isPermanentDelete) "Delete File(s) Permanently" else "Remove from Recents"
|
|
val text = if (isPermanentDelete) {
|
|
"Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone."
|
|
} else {
|
|
"Do you want to remove $count selected file(s) from the recent files list? It will reappear if you open it again from the library."
|
|
}
|
|
val confirmText = if (isPermanentDelete) "Delete" else "Remove"
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text(title) },
|
|
text = { Text(text) },
|
|
confirmButton = {
|
|
TextButton(onClick = onConfirm) { Text(confirmText) }
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit) {
|
|
val formattedDate = remember(item.timestamp) {
|
|
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp))
|
|
}
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("File Information") },
|
|
text = {
|
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
val displayName = item.displayName
|
|
val epubTitle = item.title
|
|
|
|
if (item.type != FileType.PDF && !epubTitle.isNullOrBlank()) {
|
|
InfoRow("Title:", epubTitle, maxLines = 3)
|
|
if (displayName != epubTitle) {
|
|
InfoRow("File Name:", displayName, maxLines = 2)
|
|
}
|
|
} else {
|
|
InfoRow("File Name:", displayName, maxLines = 2)
|
|
}
|
|
|
|
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
|
|
InfoRow("Author:", it, maxLines = 2)
|
|
}
|
|
|
|
InfoRow("File Type:", item.type.name)
|
|
InfoRow("Date Added:", formattedDate)
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(onClick = onDismiss) { Text("OK") }
|
|
}
|
|
)
|
|
}
|
|
|
|
|
|
@Composable
|
|
private fun InfoRow(label: String, value: String?, maxLines: Int = 1) {
|
|
if (value.isNullOrBlank()) return
|
|
Row {
|
|
Text(
|
|
text = label,
|
|
fontWeight = FontWeight.SemiBold,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
modifier = Modifier
|
|
.width(90.dp)
|
|
.padding(end = 8.dp)
|
|
)
|
|
Text(
|
|
text = value,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
maxLines = maxLines,
|
|
overflow = TextOverflow.Ellipsis
|
|
)
|
|
}
|
|
}
|
|
|
|
@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,
|
|
title = { Text("About Episteme") },
|
|
text = {
|
|
Column {
|
|
Text(
|
|
"Version: ${BuildConfig.VERSION_NAME} (Build: ${BuildConfig.VERSION_CODE})",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
modifier = Modifier.padding(bottom = 12.dp)
|
|
)
|
|
|
|
// Only show legal links in the Pro version
|
|
if (!isOss) {
|
|
HorizontalDivider()
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Text(
|
|
text = "Privacy Policy",
|
|
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary),
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable { uriHandler.openUri(PRIVACY_POLICY_URL) }
|
|
.padding(vertical = 8.dp)
|
|
)
|
|
Text(
|
|
text = "Terms of Service",
|
|
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary),
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable { uriHandler.openUri(TERMS_URL) }
|
|
.padding(vertical = 8.dp)
|
|
)
|
|
Text(
|
|
text = "Licenses",
|
|
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary),
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable { uriHandler.openUri(LICENSES_URL) }
|
|
.padding(vertical = 8.dp)
|
|
)
|
|
}
|
|
}
|
|
},
|
|
confirmButton = {
|
|
TextButton(onClick = onDismiss) { Text("Close") }
|
|
}
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun EmptyState(
|
|
title: String,
|
|
message: String,
|
|
onSelectFileClick: () -> Unit,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
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
|
|
)
|
|
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 = "Select a File")
|
|
}
|
|
}
|
|
|
|
@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("Clear All Synced Data?") },
|
|
text = { Text("Are you sure you want to permanently delete all of your book data from the cloud? This will also wipe your local library to prevent re-syncing. This action cannot be undone.") },
|
|
confirmButton = {
|
|
TextButton(
|
|
onClick = onConfirm,
|
|
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
|
) {
|
|
Text("DELETE ALL DATA")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = onDismiss) { Text("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
|
|
}
|
|
}
|
|
)
|
|
} |