book-reader/app/src/main/java/com/aryan/reader/SharedComposables.kt

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
}
}
)
}