/* * 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 . * * 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) -> Unit ): ManagedActivityResultLauncher, List<@JvmSuppressWildcards Uri>> { return rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenMultipleDocuments(), onResult = { uris: List -> 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 } } ) }