diff --git a/.gitignore b/.gitignore index 3b23ed2..f9e29ea 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ app/src/main/assets/adi-registration.properties # Local graphify knowledge graph /graphify-out/ + +# Local scratch / reference clones +scratch/ diff --git a/SCAN_IMPLEMENTATION_PLAN.md b/SCAN_IMPLEMENTATION_PLAN.md deleted file mode 100644 index b04cda4..0000000 --- a/SCAN_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,265 +0,0 @@ -# Document Scanning Implementation Plan - -## Overview -Comprehensive document scanning feature with ML-powered edge detection, multi-page support, OCR, and advanced filters. - -## Technology Stack -- **ML Kit Document Scanner** - Google's document scanning API (free, privacy-focused) -- **CameraX** - Modern camera API with automatic lifecycle management -- **iText/PDFBox** - PDF generation and manipulation -- **ML Kit Text Recognition** - OCR for searchable PDFs -- **Coil** - Image loading and caching - ---- - -## Phase 1: Basic Document Scanning βœ… IN PROGRESS -**Timeline: Current Phase** - -### Features -- βœ… Replace Wallpaper button with Scan button -- πŸ”„ Integrate ML Kit Document Scanner -- πŸ”„ Capture single document with auto edge detection -- πŸ”„ Automatic perspective correction -- πŸ”„ Image enhancement (brightness, contrast) -- πŸ”„ Convert scanned image to PDF -- πŸ”„ Save to device storage -- πŸ”„ Import from gallery - -### Implementation Tasks -1. Add ML Kit Scanner dependency -2. Create ScanDocumentScreen UI -3. Create ScanViewModel with state management -4. Implement ML Kit scanner launcher -5. Add image-to-PDF conversion -6. File storage and permissions handling -7. Gallery picker integration - -### Dependencies Required -```kotlin -// ML Kit Document Scanner -implementation("com.google.android.gms:play-services-mlkit-document-scanner:16.0.0-beta1") - -// CameraX (for custom camera if needed) -implementation("androidx.camera:camera-core:1.4.1") -implementation("androidx.camera:camera-camera2:1.4.1") -implementation("androidx.camera:camera-lifecycle:1.4.1") -implementation("androidx.camera:camera-view:1.4.1") - -// Image loading -implementation("io.coil-kt.coil3:coil-compose:3.0.4") - -// Permissions -implementation("com.google.accompanist:accompanist-permissions:0.36.0") -``` - ---- - -## Phase 2: Multi-Page Scanning πŸ“‹ PLANNED -**Timeline: After Phase 1** - -### Features -- Scan multiple pages sequentially -- Page thumbnail preview grid -- Drag-to-reorder pages -- Delete individual pages -- Add pages to existing scan -- Batch scanning mode with auto-capture -- Page counter and progress indicator - -### Implementation Tasks -1. Create multi-page state management -2. Build thumbnail grid UI with lazy layout -3. Implement drag-and-drop reordering -4. Add page manipulation actions (delete, duplicate) -5. Create batch scanning flow -6. Combine multiple images into single PDF - ---- - -## Phase 3: OCR & Text Recognition πŸ“ PLANNED -**Timeline: After Phase 2** - -### Features -- Extract text from scanned documents -- Create searchable PDFs -- Copy text from scans -- Highlight detected text regions -- Multi-language support (100+ languages) -- Text correction and editing - -### Implementation Tasks -1. Add ML Kit Text Recognition v2 dependency -2. Process scanned images through OCR -3. Embed text layer in PDF -4. Create text extraction UI -5. Implement text search in scanned PDFs -6. Add language selection - -### Dependencies Required -```kotlin -// ML Kit Text Recognition -implementation("com.google.mlkit:text-recognition:16.0.1") -implementation("com.google.mlkit:language-id:17.0.6") -``` - ---- - -## Phase 4: Advanced Scanning Features πŸš€ PLANNED -**Timeline: After Phase 3** - -### Features -#### Filters & Enhancement -- Black & White (high contrast) -- Grayscale -- Color (auto white balance) -- Magic Color (AI enhancement) -- Lightening filter -- Original (no filter) -- Custom brightness/contrast sliders - -#### Smart Features -- QR/Barcode detection and extraction -- Business card scanning with contact extraction -- Receipt scanning with amount detection -- Auto-rotate based on text orientation -- Automatic blank page detection -- ID card scanning (front/back) - -#### Organization -- Document categories (Receipt, Invoice, ID, etc.) -- Tags and labels -- Search by text content -- Sort by date/name/type -- Favorites/starred documents - -#### Cloud & Sharing -- Export to Google Drive -- Export to Dropbox -- Share via email/messaging -- Print directly -- Batch export -- Auto-backup to cloud - -### Implementation Tasks -1. Create filter processing pipeline -2. Build filter selection UI -3. Add barcode/QR scanning -4. Implement OCR-based smart features -5. Create categorization system -6. Add cloud storage integration -7. Build sharing functionality - -### Dependencies Required -```kotlin -// Barcode scanning -implementation("com.google.mlkit:barcode-scanning:17.3.0") - -// Image processing -implementation("com.github.bumptech.glide:glide:4.16.0") - -// Cloud storage -implementation("com.google.android.gms:play-services-drive:17.0.0") -``` - ---- - -## Phase 5: Pro Features & Optimization πŸ’Ž FUTURE -**Timeline: Long-term** - -### Features -- Batch OCR processing -- Automatic document classification -- Smart crop suggestions -- Background processing for large documents -- Offline mode with sync -- Export to Word/Excel -- Advanced PDF editing -- Signature capture and placement -- Form filling assistance -- Document templates - ---- - -## Technical Architecture - -### Module Structure -``` -app/ - data/ - model/ - ScannedDocument.kt - ScanPage.kt - ScanFilter.kt - repository/ - ScanRepository.kt - DocumentStorageRepository.kt - domain/ - usecase/ - ScanDocumentUseCase.kt - ProcessScanUseCase.kt - ConvertToPdfUseCase.kt - ExtractTextUseCase.kt - ui/ - screen/ - ScanDocumentScreen.kt - ScanPreviewScreen.kt - MultiPageScanScreen.kt - viewmodel/ - ScanViewModel.kt - utils/ - ImageProcessor.kt - PdfGenerator.kt - PermissionHelper.kt -``` - -### State Management -```kotlin -data class ScanUiState( - val isScanning: Boolean = false, - val scannedPages: List = emptyList(), - val currentFilter: ScanFilter = ScanFilter.AUTO, - val selectedPageIndex: Int? = null, - val error: String? = null, - val ocrProgress: Float = 0f, - val extractedText: String? = null -) - -sealed class ScanEvent { - data object StartScan : ScanEvent() - data class ScanComplete(val uri: Uri) : ScanEvent() - data class DeletePage(val index: Int) : ScanEvent() - data class ReorderPages(val from: Int, val to: Int) : ScanEvent() - data class ApplyFilter(val filter: ScanFilter) : ScanEvent() - data class SaveDocument(val name: String) : ScanEvent() - data object ExtractText : ScanEvent() -} -``` - ---- - -## Security & Privacy -- All processing done on-device (ML Kit runs locally) -- No data sent to external servers -- User consent for camera/storage permissions -- Secure file storage with encryption option -- GDPR compliant -- Optional cloud sync (user controlled) - ---- - -## Performance Considerations -- Lazy loading for multi-page thumbnails -- Background processing for OCR -- Image compression to reduce file size -- Caching processed images -- Memory-efficient bitmap handling -- Coroutine-based async operations - ---- - -## Success Metrics -- Scan completion < 3 seconds per page -- Edge detection accuracy > 95% -- OCR accuracy > 98% for printed text -- App size increase < 20MB -- Crash-free rate > 99.5% diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 00212d8..d9d3e9c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -22,4 +22,10 @@ ClearPDF uses open-source components and keeps their notices with the project. R - License: Apache License 2.0 (as attributed in the Settings screen) - Use: the app's translucent glass surfaces, backdrop effects, and shared UI components. +## Feature-set inspiration β€” Pdf_Tools + +- Project: `Karna14314/Pdf_Tools` (https://github.com/Karna14314/Pdf_Tools) +- License: Apache License 2.0 +- Use: informed ClearPDF's on-device tool set (e.g. PDF-to-Images export). ClearPDF's implementations are original code written against the app's own architecture and `backdrop` UI; no source was copied. This acknowledgement is provided in good faith for the shared feature direction. + The app does not add GPL or LGPL components for document rendering. If a future dependency changes that, its license and redistribution obligations must be reviewed before release. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e57b729..1013ce3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,8 +38,8 @@ android { applicationId = "com.chethan616.clearpdf" minSdk = 23 targetSdk = 36 - versionCode = 2 - versionName = "1.1.0" + versionCode = 3 + versionName = "1.2.0" // Keep every locale declared by the app. Filtering this to English // removes values-pt-rBR from the packaged APK, so the language picker // can appear to work while the app continues to resolve English. @@ -133,9 +133,11 @@ dependencies { implementation("org.apache.poi:poi:3.17") implementation("org.apache.poi:poi-scratchpad:3.17") + // PdfBox-Android β€” needed by PdfViewerViewModel for native PDF overlay export + implementation(libs.pdfbox.android) + // ML Kit Document Scanner & Camera implementation(libs.play.services.mlkit.scanner) - implementation(libs.play.services.mlkit.text.recognition) implementation(libs.camerax.core) implementation(libs.camerax.camera2) implementation(libs.camerax.lifecycle) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47b7fe2..029aede 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,10 @@ + @@ -25,7 +29,7 @@ android:name=".MainActivity" android:exported="true" android:hardwareAccelerated="true" - android:windowSoftInputMode="adjustNothing" + android:windowSoftInputMode="adjustResize" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> diff --git a/app/src/main/java/com/chethan616/clearpdf/MainActivity.kt b/app/src/main/java/com/chethan616/clearpdf/MainActivity.kt index 207fa40..4ccbef7 100644 --- a/app/src/main/java/com/chethan616/clearpdf/MainActivity.kt +++ b/app/src/main/java/com/chethan616/clearpdf/MainActivity.kt @@ -31,6 +31,10 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) + // A locale change restarts this Activity. DocsApp animates both halves of that restart, so + // the system's own cross-fade would just stack on top of ours. + com.chethan616.clearpdf.ui.utils.LocaleHelper.suppressActivityTransition(this) + // Request the highest available refresh rate (90Hz/120Hz) requestHighRefreshRate() @@ -60,14 +64,28 @@ class MainActivity : ComponentActivity() { else -> null }?.takeIf { uri -> isSupportedDocumentIntent(intent?.type, uri.toString()) } - // If a PDF was shared/opened, navigate to viewer with that URI - val effectiveRoute = if (incomingPdfUri != null) "pdf_viewer" else shortcutRoute + // Route an incoming document by its kind: spreadsheets β†’ interactive grid, images β†’ image + // editor, everything else β†’ the PDF viewer. + val effectiveRoute = if (incomingPdfUri != null) { + when (com.chethan616.clearpdf.utils.docKindOf(queryDisplayName(incomingPdfUri))) { + com.chethan616.clearpdf.utils.DocKind.Excel -> "spreadsheet" + com.chethan616.clearpdf.utils.DocKind.Image -> "image_editor" + else -> "pdf_viewer" + } + } else shortcutRoute setContent { DocsApp(shortcutRoute = effectiveRoute, incomingPdfUri = incomingPdfUri) } } + private fun queryDisplayName(uri: Uri): String? = runCatching { + contentResolver.query(uri, null, null, null, null)?.use { c -> + val i = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (i != -1 && c.moveToFirst()) c.getString(i) else null + } + }.getOrNull() ?: uri.lastPathSegment + private fun isSupportedDocumentIntent(mimeType: String?, uriString: String): Boolean { val lower = uriString.lowercase() val supportedExtension = listOf( diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/PdfServiceLocator.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/PdfServiceLocator.kt index bdadeff..3859eec 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/PdfServiceLocator.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/PdfServiceLocator.kt @@ -14,6 +14,8 @@ import com.kyant.pdfcore.splitter.PdfSplitter import com.kyant.pdfcore.splitter.PdfSplitterImpl import com.kyant.pdfcore.viewer.PdfViewer import com.kyant.pdfcore.viewer.PdfViewerImpl +import com.kyant.pdfcore.text.PdfTextService +import com.kyant.pdfcore.text.PdfTextServiceImpl object PdfServiceLocator { val pdfViewer: PdfViewer by lazy { PdfViewerImpl() } @@ -23,4 +25,5 @@ object PdfServiceLocator { val pdfSplitter: PdfSplitter by lazy { PdfSplitterImpl() } val pdfEditor: PdfEditor by lazy { PdfEditorImpl() } val pdfConverter: PdfConverter by lazy { PdfConverterImpl() } + val pdfTextService: PdfTextService by lazy { PdfTextServiceImpl() } } diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt index 8415dc2..015a5a1 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt @@ -13,7 +13,9 @@ data class RecentFile( val uriString: String, val timestamp: Long, val pageCount: Int = -1, - val sizeBytes: Long = -1 + val sizeBytes: Long = -1, + /** Pinned entries sort to the top and survive the [RecentFilesManager] trim. */ + val pinned: Boolean = false ) { val uri: Uri get() = Uri.parse(uriString) } @@ -30,10 +32,12 @@ object RecentFilesManager { private fun prefs(context: Context): SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + /** Pinned first, then newest first. Stable across every read, so the UI never has to re-sort. */ fun getRecents(context: Context): List { val raw = prefs(context).getString(KEY_RECENTS, null) ?: return emptyList() return try { json.decodeFromString>(raw) + .sortedWith(compareByDescending { it.pinned }.thenByDescending { it.timestamp }) } catch (_: Exception) { emptyList() } @@ -41,17 +45,29 @@ object RecentFilesManager { fun addRecent(context: Context, file: RecentFile) { val current = getRecents(context).toMutableList() - // Remove duplicate by URI + // Re-opening a pinned file must not silently unpin it β€” carry the flag forward. + val wasPinned = current.any { it.uriString == file.uriString && it.pinned } current.removeAll { it.uriString == file.uriString } - // Add to front - current.add(0, file) - // Trim to max - val trimmed = current.take(MAX_RECENTS) + current.add(0, if (wasPinned) file.copy(pinned = true) else file) + // Trim to max, but a pin is an explicit "keep this" β€” pinned entries are exempt. + val (pinned, unpinned) = current.partition { it.pinned } + val trimmed = pinned + unpinned.take((MAX_RECENTS - pinned.size).coerceAtLeast(0)) prefs(context).edit() .putString(KEY_RECENTS, json.encodeToString(trimmed)) .apply() } + /** Flips the pin on one entry. No-op if the URI isn't in the list. */ + fun togglePin(context: Context, uri: Uri) { + val target = uri.toString() + val updated = getRecents(context).map { + if (it.uriString == target) it.copy(pinned = !it.pinned) else it + } + prefs(context).edit() + .putString(KEY_RECENTS, json.encodeToString(updated)) + .apply() + } + fun clearRecents(context: Context) { prefs(context).edit().remove(KEY_RECENTS).apply() } @@ -110,13 +126,29 @@ object AppSettingsManager { private const val PREFS_NAME = "clearpdf_settings" private const val KEY_AUTO_COMPRESS = "auto_compress" private const val KEY_KEEP_ORIGINAL = "keep_original" - private const val KEY_NOTIFICATIONS = "notifications" private const val KEY_DEFAULT_QUALITY = "default_quality" private const val KEY_THEME_MODE = "theme_mode" // 0: System, 1: Light, 2: Dark + private const val KEY_SHOW_WALLPAPER = "show_wallpaper" private fun prefs(context: Context): SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + fun getShowWallpaper(context: Context): Boolean = + prefs(context).getBoolean(KEY_SHOW_WALLPAPER, true) + + fun setShowWallpaper(context: Context, value: Boolean) = + prefs(context).edit().putBoolean(KEY_SHOW_WALLPAPER, value).apply() + + // Optional user-picked background image (content URI). Null = use the built-in wallpaper. + fun getCustomWallpaper(context: Context): String? = + prefs(context).getString("custom_wallpaper_uri", null) + + fun setCustomWallpaper(context: Context, uri: String) = + prefs(context).edit().putString("custom_wallpaper_uri", uri).apply() + + fun clearCustomWallpaper(context: Context) = + prefs(context).edit().remove("custom_wallpaper_uri").apply() + fun getAutoCompress(context: Context): Boolean = prefs(context).getBoolean(KEY_AUTO_COMPRESS, true) @@ -129,12 +161,6 @@ object AppSettingsManager { fun setKeepOriginal(context: Context, value: Boolean) = prefs(context).edit().putBoolean(KEY_KEEP_ORIGINAL, value).apply() - fun getNotifications(context: Context): Boolean = - prefs(context).getBoolean(KEY_NOTIFICATIONS, false) - - fun setNotifications(context: Context, value: Boolean) = - prefs(context).edit().putBoolean(KEY_NOTIFICATIONS, value).apply() - fun getDefaultQuality(context: Context): Float = prefs(context).getFloat(KEY_DEFAULT_QUALITY, 0.7f) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt index 4164919..a932bf5 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt @@ -2,8 +2,15 @@ package com.chethan616.clearpdf.ui import android.content.Intent import android.net.Uri +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,6 +39,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources @@ -42,6 +51,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.navigation.compose.currentBackStackEntryAsState @@ -54,6 +64,7 @@ import com.chethan616.clearpdf.ui.components.DocsBottomTabs import com.chethan616.clearpdf.ui.components.LiquidButton import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.navigation.DocsNavGraph +import com.chethan616.clearpdf.ui.navigation.ROUTE_ONBOARDING import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.StarPromptEventBus import com.chethan616.clearpdf.ui.utils.rememberUISensor @@ -61,6 +72,13 @@ import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import kotlinx.coroutines.flow.collectLatest +/** Walk the ContextWrapper chain to the hosting Activity (for locale-change recreate). */ +private tailrec fun android.content.Context.findActivity(): android.app.Activity? = when (this) { + is android.app.Activity -> this + is android.content.ContextWrapper -> baseContext.findActivity() + else -> null +} + /** * Root composable for the Docs app. * Provides the wallpaper backdrop, floating bottom tabs, and navigation host. @@ -73,7 +91,36 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu com.chethan616.clearpdf.ui.utils.LocaleHelper.getLocalizedContext(context, selectedLocale) } + // Read ONCE. `setOnboardingComplete()` flips this mid-session, and re-reading it would swap the + // NavHost's start destination underneath a live back stack. A first launch that arrives via a + // share or a shortcut skips the tour and goes straight to the document β€” the flag is left unset, + // so onboarding still appears on the next ordinary cold start rather than being lost. + val needsOnboarding = remember { + !OnboardingManager.hasCompletedOnboarding(context) && + shortcutRoute == null && incomingPdfUri == null + } + // The locale the Activity actually booted with. Onboarding changes `selectedLocale` in place for + // the flow's own strings, but the rest of the app was built by `attachBaseContext` against this + // one, so only a difference from *this* value justifies a restart at the end. + val bootLocale = remember { selectedLocale } + var themeMode by rememberSaveable { mutableIntStateOf(AppSettingsManager.getThemeMode(context)) } + var showWallpaper by rememberSaveable { mutableStateOf(AppSettingsManager.getShowWallpaper(context)) } + var customWallpaper by rememberSaveable { mutableStateOf(AppSettingsManager.getCustomWallpaper(context)) } + // Decode the user's chosen background off the main thread (downsampled to the screen). + var customWallpaperBitmap by remember { mutableStateOf(null) } + LaunchedEffect(customWallpaper) { + customWallpaperBitmap = customWallpaper?.let { uriStr -> + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + runCatching { + val opts = android.graphics.BitmapFactory.Options().apply { inSampleSize = 2 } + context.contentResolver.openInputStream(android.net.Uri.parse(uriStr))?.use { + android.graphics.BitmapFactory.decodeStream(it, null, opts) + }?.asImageBitmap() + }.getOrNull() + } + } + } var showStarPrompt by rememberSaveable { mutableStateOf(false) } val systemDark = isSystemInDarkTheme() val isDarkMode = when (themeMode) { @@ -102,8 +149,45 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu } } + // ── Locale-switch choreography ────────────────────────────────────────────────────────────── + // The switch still restarts the Activity (see LocaleHelper.markLocaleFadePending), but the two + // halves are animated so it reads as one deliberate cross-fade instead of a hard flash. + var localeSwitching by remember { mutableStateOf(false) } + val exitProgress by animateFloatAsState( + targetValue = if (localeSwitching) 0f else 1f, + animationSpec = tween(200, easing = FastOutSlowInEasing), + label = "localeExit" + ) + val fadeInPending = remember { com.chethan616.clearpdf.ui.utils.LocaleHelper.consumeLocaleFadePending(context) } + var entered by remember { mutableStateOf(!fadeInPending) } + LaunchedEffect(Unit) { entered = true } + val enterProgress by animateFloatAsState( + targetValue = if (entered) 1f else 0f, + animationSpec = tween(260, easing = FastOutSlowInEasing), + label = "localeEnter" + ) + LaunchedEffect(localeSwitching) { + if (localeSwitching) { + // Restart once the fade has actually landed, not while it is still running. + kotlinx.coroutines.delay(210) + context.findActivity()?.let { activity -> + activity.recreate() + com.chethan616.clearpdf.ui.utils.LocaleHelper.suppressActivityTransition(activity) + } + } + } + Box( - Modifier.fillMaxSize(), + Modifier + .fillMaxSize() + // Root-level only. The transform composites the finished frame, so the glass inside is + // never re-sampled β€” it does not violate the "don't move glass" rule. + .graphicsLayer { + alpha = exitProgress * enterProgress + val s = lerp(0.98f, 1f, exitProgress) * lerp(1.02f, 1f, enterProgress) + scaleX = s + scaleY = s + }, contentAlignment = Alignment.TopCenter ) { val backdrop = rememberLayerBackdrop() @@ -133,9 +217,10 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu } } - // Handle app shortcut deep links + // Handle app shortcut deep links. Guarded on the gate so a shortcut can never fire on top of + // the tour β€” though `needsOnboarding` is already false whenever a shortcut is present. LaunchedEffect(shortcutRoute) { - if (shortcutRoute != null) { + if (shortcutRoute != null && !needsOnboarding) { navController.navigate(shortcutRoute) { launchSingleTop = true } } } @@ -150,21 +235,49 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu } } - Image( - painterResource(if (!isDarkMode) R.drawable.wallpaper_light else R.drawable.wallpaper_dark), - contentDescription = null, - modifier = Modifier - .layerBackdrop(backdrop) - .fillMaxSize(), - contentScale = ContentScale.Crop - ) + val contentBackdrop = rememberLayerBackdrop() CompositionLocalProvider( LocalResources provides localizedContext.resources, LocalIsDarkMode provides isDarkMode ) { Box(Modifier.fillMaxSize()) { - DocsNavGraph( + // Captured layer = wallpaper + the live screen. The floating tab bar + // (below) samples THIS, so it reflects real content scrolling under it + // instead of the static wallpaper PNG. Per-screen glass keeps sampling + // the wallpaper-only `backdrop`; it lives INSIDE this layer but samples a + // different backdrop, so there is no glass-on-glass feedback loop. + Box(Modifier.fillMaxSize().layerBackdrop(contentBackdrop)) { + if (showWallpaper) { + val customBmp = customWallpaperBitmap + if (customBmp != null) { + Image( + bitmap = customBmp, + contentDescription = null, + modifier = Modifier.layerBackdrop(backdrop).fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Image( + painterResource(if (!isDarkMode) R.drawable.wallpaper_light else R.drawable.wallpaper_dark), + contentDescription = null, + modifier = Modifier.layerBackdrop(backdrop).fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } else { + // Wallpaper off: fall back to a NEUTRAL GREY base (not pure white/black). + // Apple never puts glass on pure #FFF or #000 β€” the translucent surfaces + // would vanish. A light grey (#E9E9EE) / elevated dark grey (#1C1C1E) keeps + // the liquid-glass panels and buttons legible with real depth. + Box( + Modifier + .layerBackdrop(backdrop) + .fillMaxSize() + .background(if (!isDarkMode) Color(0xFFE9E9EE) else Color(0xFF1C1C1E)) + ) + } + DocsNavGraph( navController = navController, backdrop = backdrop, selectedTab = selectedTab, @@ -176,21 +289,62 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu themeMode = it AppSettingsManager.setThemeMode(context, it) }, + showWallpaper = showWallpaper, + onShowWallpaperChanged = { + showWallpaper = it + AppSettingsManager.setShowWallpaper(context, it) + }, + hasCustomWallpaper = customWallpaper != null, + onCustomWallpaperChanged = { customWallpaper = it }, selectedLocale = selectedLocale, onLocaleChanged = { code -> val normalized = com.chethan616.clearpdf.ui.utils.LocaleHelper.normalizeForUi(code) if (normalized != selectedLocale) { - selectedLocale = normalized + // Persist the choice, then recreate the Activity so attachBaseContext + // rebuilds every resource in the new locale (the Compose-only path did + // not actually switch strings). The recreate is deferred until the + // fade-out finishes β€” see `localeSwitching` above. com.chethan616.clearpdf.ui.utils.LocaleHelper.applyLocale( context = context, languageTag = normalized, recreate = false, updateAppCompat = false ) + com.chethan616.clearpdf.ui.utils.LocaleHelper.markLocaleFadePending(context) + selectedLocale = normalized + localeSwitching = true + } + }, + incomingPdfUri = incomingPdfUri, + startDestination = if (needsOnboarding) ROUTE_ONBOARDING else "home", + // In place: persist + update the hoisted state, which re-provides LocalResources + // above, so every `stringResource` in the flow re-resolves. No recreate, or the + // tour would relaunch at page one mid-flow. + onOnboardingLocaleSelected = { code -> + val normalized = com.chethan616.clearpdf.ui.utils.LocaleHelper.normalizeForUi(code) + if (normalized != selectedLocale) { + OnboardingManager.setSelectedLocale(context, normalized) + selectedLocale = normalized } }, - incomingPdfUri = incomingPdfUri + onOnboardingFinished = { + OnboardingManager.setOnboardingComplete(context) + // Only now, and only if the choice actually differs from what + // `attachBaseContext` built the app with, is a restart worth its cost β€” it + // is what makes non-Compose strings (toasts, notifications) follow suit. + if (selectedLocale != bootLocale) { + com.chethan616.clearpdf.ui.utils.LocaleHelper.applyLocale( + context = context, + languageTag = selectedLocale, + recreate = false, + updateAppCompat = false + ) + com.chethan616.clearpdf.ui.utils.LocaleHelper.markLocaleFadePending(context) + localeSwitching = true + } + } ) + } androidx.compose.animation.AnimatedVisibility( visible = showBottomTabs, @@ -229,7 +383,7 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu DocsBottomTabs( selectedTab = { selectedTab }, onTabSelected = onBottomTabSelected, - backdrop = backdrop, + backdrop = contentBackdrop, modifier = Modifier .padding(horizontal = 24.dp, vertical = 12.dp) ) @@ -248,9 +402,15 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu properties = DialogProperties(usePlatformDefaultWidth = false) ) { Column( + // A Dialog is a separate window and cannot sample the in-window + // backdrop layer β€” liquidGlassPanel here rendered the raw wallpaper + // PNG. Use a solid themed card instead (same fix as the save/signature + // dialogs). Modifier .fillMaxWidth(0.88f) - .liquidGlassPanel(backdrop, uiSensor) + .clip(RoundedCornerShape(28.dp)) + .background(if (isDarkMode) Color(0xFF1B1E25) else Color(0xFFF5F6F8)) + .border(1.dp, if (isDarkMode) Color.White.copy(0.10f) else Color.Black.copy(0.06f), RoundedCornerShape(28.dp)) .padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/DecryptingAnimation.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/DecryptingAnimation.kt new file mode 100644 index 0000000..e54efbd --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/DecryptingAnimation.kt @@ -0,0 +1,132 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import com.kyant.shapes.RoundedRectangle +import kotlinx.coroutines.delay + +/** + * The "decrypting" animation: a **blue liquid-glass** document whose encrypted lines resolve as a + * scan beam sweeps down it, in the flat self-drawing style of the onboarding page-5 demos + * (`DemoAnnotate`'s signature stroking itself) β€” NOT the page-6 glass-disc medallion. + * + * The panel is a real [viewerGlass] surface tinted blue, so it refracts the wallpaper [backdrop] like + * the rest of the app's chrome; on top of it each line is faint until the beam reaches it, then it + * fills in solid **left-to-right** (drawn, not faded). Runs the demos' rise β†’ hold β†’ reset β†’ gap loop + * so it reads as a repeating demonstration for as long as the decrypt is in flight. + */ +@Composable +fun DecryptingAnimation( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + // The blue glass tint and the two ink strengths the lines resolve between. + val blueGlass = Color(0xFF1E6BFF).copy(alpha = 0.34f) + val encrypted = Color.White.copy(alpha = 0.22f) + val decrypted = Color(0xFFEAF3FF) + val beam = Color(0xFF9AD0FF) + + // Same cadence as rememberDemoLoop: lead-in, decrypt (rise) β†’ hold on the finished page β†’ reset + // (re-lock) β†’ short gap β†’ repeat. Reset-and-replay is exactly how DemoAnnotate loops. + var target by remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + delay(220) + while (true) { + target = 1f + delay(1500) // scan down + dwell on the decrypted page + target = 0f + delay(520) // re-lock gap + } + } + val p by animateFloatAsState(target, tween(1050, easing = FastOutSlowInEasing), label = "decryptScan") + + Box( + modifier + .size(width = 176.dp, height = 132.dp) + .viewerGlass(backdrop, blueGlass, shape = { RoundedRectangle(20f.dp) }) + ) { + Canvas(Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 22.dp)) { + val w = size.width + val h = size.height + val barH = 6.dp.toPx() + val radius = CornerRadius(barH / 2f, barH / 2f) + + // The document's lines: (vertical fraction, width fraction), varied like real text. + val lines = listOf( + 0.02f to 0.55f, // a short "heading" + 0.22f to 1.00f, + 0.40f to 0.82f, + 0.58f to 0.95f, + 0.76f to 0.66f, + 0.94f to 0.88f + ) + + // The beam sweeps from just above the first line to just past the last, so every line is + // fully resolved before the dwell. + val scanY = lerp(-0.06f * h, 1.10f * h, p) + val band = 0.18f * h // how far ahead of the beam a line begins resolving + + lines.forEach { (fy, fw) -> + val y = fy * h + val full = w * fw + // Encrypted (base) bar β€” always present, faint. + drawRoundRect( + color = encrypted, + topLeft = Offset(0f, y), + size = Size(full, barH), + cornerRadius = radius + ) + // Decrypted fill: grows to full width as the beam passes β€” a left-to-right "draw". + val reveal = ((scanY - y + band) / band).coerceIn(0f, 1f) + if (reveal > 0f) { + drawRoundRect( + color = decrypted, + topLeft = Offset(0f, y), + size = Size(full * reveal, barH), + cornerRadius = radius + ) + } + } + + // The scan beam β€” a bright rule with a soft trail above it (where it has just decrypted). + // Hidden during the dwell/reset, when it's off the page. + if (scanY in 0f..h) { + val trail = 0.16f * h + val top = (scanY - trail).coerceAtLeast(0f) + drawRect( + color = beam.copy(alpha = 0.16f), + topLeft = Offset(0f, top), + size = Size(w, scanY - top) + ) + drawLine( + color = beam, + start = Offset(0f, scanY), + end = Offset(w, scanY), + strokeWidth = 2f.dp.toPx(), + cap = StrokeCap.Round + ) + } + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassCapsuleMenu.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassCapsuleMenu.kt new file mode 100644 index 0000000..f126538 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassCapsuleMenu.kt @@ -0,0 +1,149 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.chethan616.clearpdf.ui.utils.UISensor +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.highlight.HighlightStyle +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import com.kyant.shapes.Capsule + +/** One circular action inside a [GlassCapsuleMenu]. */ +data class GlassMenuAction( + val icon: ImageVector, + val label: String, + val tint: Color, + val onClick: () -> Unit +) + +private val ActionSize = 40.dp + +/** How much of the morph each successive circle is delayed by, as a fraction of `progress`. */ +private const val StaggerFraction = 0.07f + +/** + * A contextual menu shaped like the rest of the design language: **one** glass capsule with flat, + * tinted circles inside it β€” not a cluster of free-floating glass buttons. + * + * Two deliberate choices, both for the same reason: + * + * - The capsule is a single [drawBackdrop] surface, so opening the menu costs one blur+lens pass + * instead of one per action. The circles are flat [background]s, which are free. + * - **The capsule only fades.** A glass surface samples the backdrop under the region it currently + * covers, so scaling or translating it re-runs blur+lens every frame. The morph therefore lives on + * the flat circles, which can scale and rise as much as they like. + * + * [progress] (0..1) is owned by the caller, so the menu can be anchored and driven by whatever + * gesture opened it. The per-circle stagger is derived from it β€” no extra animation state. + */ +@Composable +fun GlassCapsuleMenu( + actions: List, + backdrop: Backdrop, + uiSensor: UISensor, + progress: Float, + modifier: Modifier = Modifier, + surfaceColor: Color = Color.Unspecified +) { + val container = if (surfaceColor == Color.Unspecified) { + Color.White.copy(0.10f) + } else { + surfaceColor + } + + Row( + modifier + .graphicsLayer { alpha = progress.coerceIn(0f, 1f) } + .drawBackdrop( + backdrop = backdrop, + shape = { Capsule }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + highlight = { + Highlight(style = HighlightStyle.Default(angle = uiSensor.gravityAngle, falloff = 2f)) + }, + shadow = { Shadow(radius = 10f.dp, color = Color.Black.copy(alpha = 0.14f)) }, + innerShadow = { InnerShadow(radius = 2f.dp, alpha = 0.25f) }, + onDrawSurface = { drawRect(container) } + ) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + actions.forEachIndexed { index, action -> + // Cascade left-to-right by re-mapping the shared progress rather than starting a spring + // per circle β€” `spring()` has no delay parameter, and one clock keeps them in lockstep. + val head = index * StaggerFraction + val span = (1f - head).coerceAtLeast(0.001f) + val local = ((progress - head) / span).coerceIn(0f, 1f) + CapsuleAction(action, local) + } + } +} + +@Composable +private fun CapsuleAction(action: GlassMenuAction, local: Float) { + val interaction = remember { MutableInteractionSource() } + val pressed by interaction.collectIsPressedAsState() + val press by animateFloatAsState( + if (pressed) 0.90f else 1f, + spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessMedium), + label = "capsuleActionPress" + ) + + Box( + Modifier + .size(ActionSize) + .graphicsLayer { + val s = lerp(0.4f, 1f, local) * press + scaleX = s + scaleY = s + alpha = local + translationY = lerp(14f, 0f, local) * density + } + .clip(CircleShape) + .background(action.tint.copy(0.85f)) + .clickable( + interactionSource = interaction, + indication = null, + role = Role.Button, + onClick = action.onClick + ), + contentAlignment = Alignment.Center + ) { + Icon(action.icon, action.label, Modifier.size(19.dp), Color.White) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassMotion.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassMotion.kt new file mode 100644 index 0000000..42999cf --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassMotion.kt @@ -0,0 +1,37 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.spring + +/** + * The app's shared motion vocabulary, factored out of `ShareMorphButton` so the search bars, title + * pills and pressable tiles all move with the same physics instead of each screen inventing its own + * spring. + * + * The split matters: **bounce belongs on draw-time properties** (scale, translation, rotation) and + * **not on layout** (height, width). A bouncing layout property re-measures on every overshoot frame, + * which forces `drawBackdrop` to re-run its blur and lens at a new size. So [morph] is used for + * scale, [settle] for anything that changes real size, and [fade] for alpha β€” a bouncing alpha just + * reads as a flicker. + */ +object GlassMotion { + + /** Underdamped: overshoots ~8% and settles. The capsule/bar "springs open" feel. */ + fun morph(): SpringSpec = spring(dampingRatio = 0.58f, stiffness = 420f) + + /** Snappier and bouncier β€” for small elements that should pop, like an icon landing. */ + fun pop(): SpringSpec = spring(dampingRatio = 0.45f, stiffness = 500f) + + /** Critically damped. For alpha, and for layout properties where overshoot costs a re-measure. */ + fun settle(): SpringSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessMedium) + + /** Alias of [settle], named for intent at call sites that animate opacity. */ + fun fade(): SpringSpec = settle() + + /** Press-down and bounce-back, matching LiquidButton's deformation. */ + fun press(): SpringSpec = spring(dampingRatio = 0.42f, stiffness = Spring.StiffnessMedium) + + /** Uniform pressed scale for tiles, rows and morphing buttons. */ + const val PressedScale = 0.94f +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassPrimitives.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassPrimitives.kt new file mode 100644 index 0000000..7221f9d --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassPrimitives.kt @@ -0,0 +1,236 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.LayerBackdrop + +/** + * Shared glass design-system primitives (Item 4). These are thin wrappers over the + * existing LiquidButton / LiquidIconButton / liquidGlassPanel so every screen shares + * the same spacing, corner radii, typography and tint β€” instead of re-deriving them. + * The document/page surface intentionally stays solid + readable; only floating UI + * uses glass. + */ +object GlassDimens { + val ScreenPadding = 16.dp + val SectionGap = 16.dp + val SectionRadius = 24.dp + val InnerRadius = 16.dp + val SectionPadding = 20.dp + val TitleSize: TextUnit = 16.sp +} + +/** Solid (non-refractive) glass section card. Used for settings-style grouped rows + * where a translucent panel would hurt text legibility. */ +fun Modifier.glassSection(isLight: Boolean, radius: Dp = GlassDimens.SectionRadius): Modifier { + val container = if (isLight) Color.White.copy(0.68f) else Color(0xFF161820).copy(0.72f) + val borderCol = if (isLight) Color.White.copy(0.80f) else Color.White.copy(0.12f) + return this + .clip(RoundedCornerShape(radius)) + .background(container) + .border(1.dp, borderCol, RoundedCornerShape(radius)) +} + +/** Standard section header: M3 rounded icon tile + title, with an optional trailing slot. */ +@Composable +fun GlassSectionHeader( + title: String, + icon: ImageVector, + iconTint: Color, + titleColor: Color, + modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null +) { + Row( + modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + Modifier.size(30.dp).clip(RoundedCornerShape(9.dp)).background(iconTint.copy(0.14f)), + contentAlignment = Alignment.Center + ) { + Icon(icon, null, Modifier.size(18.dp), iconTint) + } + BasicText( + title, + style = TextStyle(titleColor, GlassDimens.TitleSize, fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + trailing?.invoke() + } +} + +/** + * The app's standard DESTRUCTIVE action button β€” the *exact* treatment used by the viewer's + * insert-text "Delete": a red-tinted (`#EF5350 @0.22`) liquid-glass capsule with a white label + * (and an optional leading icon). Use this for every delete / clear / remove *text* action so + * they read identically app-wide instead of each screen re-deriving a red button. + */ +@Composable +fun DestructiveGlassButton( + text: String, + onClick: () -> Unit, + backdrop: Backdrop, + modifier: Modifier = Modifier, + icon: ImageVector? = null +) { + LiquidButton( + onClick = onClick, + backdrop = backdrop, + surfaceColor = Color(0xFFEF5350).copy(0.22f), + modifier = modifier + ) { + if (icon != null) Icon(icon, null, Modifier.size(16.dp), Color.White) + BasicText(text, style = TextStyle(Color.White, 13.sp, fontWeight = FontWeight.Medium)) + } +} + +/** Small tinted value pill (e.g. a "72%" quality badge). */ +@Composable +fun GlassChip( + text: String, + color: Color, + modifier: Modifier = Modifier +) { + Box( + modifier + .clip(RoundedCornerShape(8.dp)) + .background(color.copy(0.14f)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + BasicText(text, style = TextStyle(color, 13.sp, fontWeight = FontWeight.Bold)) + } +} + +/** + * Standard tool-screen shell: a floating back button + glass top bar with the shared + * staggered fade+rise entrance, and a scrollable content column. Replaces the ~45 lines + * of identical animation/header boilerplate every tool screen used to hand-roll. + */ +@Composable +fun ToolScaffold( + title: String, + backdrop: LayerBackdrop, + onBack: () -> Unit, + modifier: Modifier = Modifier, + /** Retained for source compatibility; the title pill has a fixed 13 sp label like the viewer's. */ + @Suppress("UNUSED_PARAMETER") titleFontSize: TextUnit = 18.sp, + headerTrailing: (@Composable RowScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit +) { + val isLight = !LocalIsDarkMode.current + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val density = LocalDensity.current.density + + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { isVisible = true } + + val topSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) + val bodySpec = tween(durationMillis = 600, delayMillis = 100, easing = FastOutSlowInEasing) + val topAlpha by animateFloatAsState(if (isVisible) 1f else 0f, topSpec, label = "toolTopA") + val bodyAlpha by animateFloatAsState(if (isVisible) 1f else 0f, bodySpec, label = "toolBodyA") + val bodyY by animateFloatAsState(if (isVisible) 0f else 24f, bodySpec, label = "toolBodyY") + + // The header floats ABOVE the scrolling body (see GlassScreenScaffold) rather than sitting in a + // Column above it, so cards pass under the glass instead of stopping at its edge β€” and the + // header samples wallpaper + live content, so its refraction is finally of something real. It + // used to sample a layer captured *below* it, which meant it refracted nothing at all. + GlassScreenScaffold( + backdrop = backdrop, + modifier = modifier, + contentBottomPadding = GlassDimens.ScreenPadding, + header = { headerBackdrop -> + // Same header trio as the PDF/spreadsheet viewers: back circle Β· centered title pill Β· + // trailing action. The title is the viewer's page-pill widget with different text, so + // every tool screen reads as the same family as the viewer. + // + // Fade only β€” `topY` is gone. Translating a glass surface re-runs its blur+lens against + // a new region on every frame of the entrance. + Row( + Modifier + .fillMaxWidth() + .graphicsLayer { alpha = topAlpha }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LiquidIconButton(onClick = onBack, backdrop = headerBackdrop, surfaceColor = Color.White.copy(0.08f)) { + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(16.dp), text) + } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + GlassTitlePill(text = title, backdrop = headerBackdrop) + } + if (headerTrailing != null) { + headerTrailing.invoke(this) + } else { + // Balances the back circle so the pill sits optically centered. + Spacer(Modifier.size(40.dp)) + } + } + } + ) { contentPadding -> + Column( + Modifier + .fillMaxSize() + // Shrink the scrollable body by the keyboard height so the focused field can + // scroll above the IME. Compose text fields auto-bring-into-view within this + // scroll parent; the inset is released when the keyboard closes. + .imePadding() + .verticalScroll(rememberScrollState()) + // Inside the scroll, so the header's clearance scrolls away like a LazyColumn's + // contentPadding rather than pinning a permanent gap. + .padding(contentPadding) + .graphicsLayer { alpha = bodyAlpha; translationY = bodyY * density }, + verticalArrangement = Arrangement.spacedBy(GlassDimens.SectionGap), + content = content + ) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassScreenHeader.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassScreenHeader.kt new file mode 100644 index 0000000..849433e --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassScreenHeader.kt @@ -0,0 +1,141 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop + +/** The chrome row's height β€” [LiquidButton]'s hardcoded 48 dp capsule, so the pill fills it exactly. */ +val GlassHeaderHeight = 48.dp + +/** Breathing room between the pinned header and the first thing that scrolls under it. */ +private val DefaultHeaderGap = 12.dp + +/** + * The app-wide screen shell: content in a captured layer, chrome floating **above** it. + * + * Every screen used to stack its header and its body in a [androidx.compose.foundation.layout.Column], + * which meant the two occupied disjoint bounds β€” the header could never be overlapped, but content + * could never flow under it either, and on Home/Tools the header lived inside the `LazyColumn` and + * simply scrolled away. Only the PDF viewer got it right. This extracts that structure: + * + * 1. [content] is composed first, inside a `Box` captured into its own [rememberLayerBackdrop]. + * 2. [header] is composed last, so it draws on top of everything the content puts on screen. + * 3. The header is handed `wallpaper + live content` as its backdrop, so its glass refracts the cards + * actually sliding under it rather than a frozen wallpaper. The header sits OUTSIDE the captured + * Box, so there is no glass-on-glass feedback loop; panels *inside* [content] keep sampling the + * wallpaper [backdrop] as before. + * + * [content] receives a [PaddingValues] that already clears the status bar, the header and the + * navigation bar β€” feed it straight into a `LazyColumn`'s `contentPadding`, or into + * `Modifier.padding(...)` for a `verticalScroll` body. + */ +@Composable +fun GlassScreenScaffold( + backdrop: Backdrop, + modifier: Modifier = Modifier, + contentHorizontalPadding: Dp = GlassDimens.ScreenPadding, + contentBottomPadding: Dp = 0.dp, + headerHorizontalPadding: Dp = GlassDimens.ScreenPadding, + headerGap: Dp = DefaultHeaderGap, + header: @Composable (headerBackdrop: Backdrop) -> Unit, + content: @Composable BoxScope.(contentPadding: PaddingValues) -> Unit +) { + val contentBackdrop = rememberLayerBackdrop() + // Wallpaper first, live content composited over it β€” the content layer is transparent wherever + // no card is drawn, so the header still refracts the wallpaper in the gaps. + val headerBackdrop = rememberCombinedBackdrop(backdrop, contentBackdrop) + + val statusTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + val contentPadding = PaddingValues( + start = contentHorizontalPadding, + end = contentHorizontalPadding, + top = statusTop + GlassHeaderHeight + headerGap, + bottom = navBottom + contentBottomPadding + ) + + Box(modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().layerBackdrop(contentBackdrop)) { + content(contentPadding) + } + Box( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = headerHorizontalPadding) + .height(GlassHeaderHeight), + contentAlignment = Alignment.Center + ) { + header(headerBackdrop) + } + } +} + +/** + * The header trio every screen hand-rolled: back circle Β· centred [GlassTitlePill] Β· trailing action. + * + * Pass `onBack = null` for a root screen (Settings, Scan) β€” the slot becomes a spacer so the pill + * stays optically centred. Same for [trailing]. + */ +@Composable +fun GlassScreenHeaderRow( + title: String, + backdrop: Backdrop, + onBack: (() -> Unit)?, + modifier: Modifier = Modifier, + titleFontFamily: FontFamily? = null, + trailing: (@Composable RowScope.() -> Unit)? = null +) { + val text = LiquidGlassColors.text(LocalIsDarkMode.current) + Row( + modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (onBack != null) { + LiquidIconButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(16.dp), text) + } + } else { + Spacer(Modifier.size(40.dp)) + } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + GlassTitlePill(text = title, backdrop = backdrop, fontFamily = titleFontFamily) + } + if (trailing != null) trailing() else Spacer(Modifier.size(40.dp)) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt new file mode 100644 index 0000000..e3c24ae --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassSearchHeader.kt @@ -0,0 +1,410 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.UISensor +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.highlight.HighlightStyle +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import com.kyant.shapes.Capsule + +/** Matches [LiquidButton]'s 48 dp capsule, so the pill and the circle share one baseline. */ +private val HeaderHeight = 48.dp + +/** Deliberately smaller than the 48 dp row β€” the search circle is a secondary affordance. */ +private val HeaderCircleSize = 40.dp + +/** Gap between the row's three slots. */ +private val HeaderGap = 10.dp + +/** + * The search capsule is deliberately slimmer than the 48 dp row it sits in β€” a search field is a + * transient input, not chrome, so it shouldn't carry the same visual weight as the title pill. + * Shared by every search surface in the app (Home, Tools, both viewers' find bars) so there is one + * search shape rather than three. + */ +val GlassSearchPillHeight = 36.dp + +/** + * The screen header for Home and Tools. Same trio as the PDF viewer's top bar β€” a leading slot, a + * centered [GlassTitlePill], and a circular [LiquidIconButton] β€” so the top-level screens and the + * viewers read as one family. + * + * Search is a *mode*, per Apple HIG: tapping the circle collapses the pill and opens the search + * field, and the circle becomes a cancel affordance. Back exits. While searching, the leading slot + * collapses to zero width and hands its space to the field, so the field runs the full row width up + * to the cancel circle. + * + * A single [updateTransition] drives the pill collapse, the leading slot's width, the field's scale + * and both icon cross-fades β€” one frame clock, so nothing can drift. It forks into three floats by + * what each one is allowed to do: `progress` owns the layout width and stays critically damped (an + * overshooting width re-measures the glass and re-runs its blur+lens at a new size, and a negative + * one throws), `fade` owns alpha and stays critically damped (overshoot clips at 1.0 and reads as a + * flicker), and `bounce` owns the scales and the icon spin, where overshoot is the whole point. + */ +@Composable +fun GlassSearchHeader( + title: String, + backdrop: Backdrop, + uiSensor: UISensor, + query: String, + onQueryChange: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + searchHint: String, + modifier: Modifier = Modifier, + onTitleClick: (() -> Unit)? = null, + titleFontFamily: FontFamily? = null, + leading: @Composable RowScope.() -> Unit = { Box(Modifier.size(HeaderCircleSize)) } +) { + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + val transition = updateTransition(active, label = "searchHeader") + // Owns the leading slot's real WIDTH, so it must never overshoot β€” a negative width throws. + val progress by transition.animateFloat( + transitionSpec = { GlassMotion.settle() }, + label = "searchProgress" + ) { if (it) 1f else 0f } + val fade by transition.animateFloat( + transitionSpec = { GlassMotion.fade() }, + label = "searchFade" + ) { if (it) 1f else 0f } + // The bounce rides its own clock so it can overshoot without dragging `progress` β€” and therefore + // the layout width β€” past 1. Everything it drives is a draw-time property, so the overshoot costs + // a layer-matrix update, not a re-measure: `drawBackdrop` never re-runs its blur or lens. + // Open only. Overshooting on close drives the scale under its floor and reads as a glitch. + val bounce by transition.animateFloat( + transitionSpec = { if (targetState) GlassMotion.morph() else GlassMotion.settle() }, + label = "searchBounce" + ) { if (it) 1f else 0f } + + val dismiss = { + onQueryChange("") + onActiveChange(false) + } + + BackHandler(enabled = active) { dismiss() } + + LaunchedEffect(active) { + if (active) runCatching { focusRequester.requestFocus() } else keyboard?.hide() + } + + // Only compose the side that is at least partly visible. Once the transition settles, exactly + // one of the two exists, so the hidden text field can never hold focus or swallow taps. + val showTitle = fade < 0.999f + val showField = fade > 0.001f + + Row( + modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Idle, this balances the trailing circle so the title pill sits dead-centre. As search + // opens it collapses β€” width and its trailing gap both go to zero β€” so the field gets the + // whole row. `progress` is critically damped, so the width only ever shrinks monotonically; + // the clamp is belt-and-braces, because Modifier.width() throws on a negative value. + val leadingWidth = (HeaderCircleSize + HeaderGap) * (1f - progress).coerceAtLeast(0f) + Box( + Modifier + .width(leadingWidth) + .graphicsLayer { alpha = 1f - fade }, + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically, content = leading) + } + + Box(Modifier.weight(1f).height(HeaderHeight), contentAlignment = Alignment.Center) { + if (showTitle) { + // The pill shrinks toward the circle as search takes over. + Box( + Modifier.graphicsLayer { + alpha = 1f - fade + // Clamped: the pill only needs the eased shape, not the overshoot β€” it is on + // its way out and a rebound would fight the fade. + val s = lerp(1f, 0.86f, bounce.coerceIn(0f, 1f)) + scaleX = s + scaleY = s + } + ) { + GlassTitlePill( + text = title, + backdrop = backdrop, + onClick = onTitleClick, + fontFamily = titleFontFamily + ) + } + } + if (showField) { + Box( + Modifier + .fillMaxWidth() + .graphicsLayer { + alpha = fade + // Grow out of the circle: the capsule's right edge is pinned, so it + // unfurls leftward, overshoots past full width and springs back. The + // overshoot runs into the leading slot, which has collapsed by then. + transformOrigin = TransformOrigin(1f, 0.5f) + scaleX = lerp(0.32f, 1f, bounce) + scaleY = lerp(0.72f, 1f, bounce) + } + ) { + GlassSearchPill( + query = query, + onQueryChange = onQueryChange, + hint = searchHint, + backdrop = backdrop, + uiSensor = uiSensor, + modifier = Modifier.fillMaxWidth(), + focusRequester = focusRequester, + onSubmit = { keyboard?.hide() }, + // The transition above already unfurls the pill out of the search circle; + // its own spring-in would stack on top of that. + animateIn = false + ) + } + } + } + + Spacer(Modifier.width(HeaderGap)) + + LiquidIconButton( + onClick = { if (active) dismiss() else onActiveChange(true) }, + backdrop = backdrop, + modifier = Modifier.size(HeaderCircleSize) + ) { + val tint = LiquidGlassColors.text(LocalIsDarkMode.current) + // The icon spins a quarter turn as it swaps, so the circle feels like it re-purposes + // rather than just blinking to a new glyph. On `bounce`, so it overshoots the turn in + // step with the capsule instead of arriving ahead of it. + Box( + Modifier.graphicsLayer { rotationZ = lerp(0f, 90f, bounce) }, + contentAlignment = Alignment.Center + ) { + if (showTitle) { + Icon( + Icons.Rounded.Search, + stringResource(R.string.search_action), + Modifier.size(18.dp).graphicsLayer { alpha = 1f - fade }, + tint + ) + } + if (showField) { + CloseCrossIcon(Modifier.size(14.dp).graphicsLayer { alpha = fade }, tint) + } + } + } + } +} + +/** + * The app's one and only search capsule β€” a 36 dp glass pill carrying [GlassTitlePill]'s typography + * (13 sp SemiBold), surface tint and spring-in, so a search field reads as the same material as the + * title pill, just slimmer. Mirrors [LiquidGlassTopBar]'s effect stack; it exists as a separate + * composable rather than a slot on [LiquidGlassTopBar] because the LiquidGlass* components are not + * to be modified. + * + * Used by [GlassSearchHeader] (Home, Tools) and by the viewers' find bar, so all four search + * surfaces are literally the same widget. + * + * @param animateIn set `false` when a caller already animates the pill in (e.g. [GlassSearchHeader] + * grows it out of the search circle) β€” otherwise the two entrances stack. + * @param viewerChrome set `true` inside the viewers, where the pill wears [viewerGlass]'s lighter + * stack so it matches the top bar's title pill instead of the app-chrome one Home and Tools use. + * @param trailing an optional slot before the clear button, e.g. the find bar's "3 / 12" counter. + */ +@Composable +fun GlassSearchPill( + query: String, + onQueryChange: (String) -> Unit, + hint: String, + backdrop: Backdrop, + uiSensor: UISensor, + modifier: Modifier = Modifier, + focusRequester: FocusRequester? = null, + onSubmit: () -> Unit = {}, + animateIn: Boolean = true, + viewerChrome: Boolean = false, + surfaceColor: Color = Color.Unspecified, + contentColor: Color = Color.Unspecified, + hintColor: Color = Color.Unspecified, + trailing: (@Composable RowScope.() -> Unit)? = null +) { + val isDarkMode = LocalIsDarkMode.current + val isLightTheme = !isDarkMode + // Same expression GlassTitlePill resolves, so the pill and the field are one material. + val containerColor = if (surfaceColor.isSpecified) surfaceColor + else if (isLightTheme) Color(0xFFFAFAFA).copy(0.35f) else Color(0xFF1E1E1E).copy(0.35f) + val text = if (contentColor.isSpecified) contentColor else LiquidGlassColors.text(isDarkMode) + val sub = if (hintColor.isSpecified) hintColor else LiquidGlassColors.secondary(isDarkMode) + + // The title pill's entrance, verbatim β€” a soft, critically-damped settle. Read inside a + // graphicsLayer so it only invalidates draw, never composition. + var shown by remember { mutableStateOf(!animateIn) } + LaunchedEffect(Unit) { shown = true } + val enterScale by animateFloatAsState( + if (shown) 1f else 0.9f, + spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow), + label = "searchPillScale" + ) + val enterAlpha by animateFloatAsState( + if (shown) 1f else 0f, + spring(dampingRatio = 1f, stiffness = Spring.StiffnessMedium), + label = "searchPillAlpha" + ) + + Row( + modifier + .graphicsLayer { + scaleX = enterScale + scaleY = enterScale + alpha = enterAlpha + } + // Two stacks, one shape. In the viewers the pill has to be the same material as the top + // bar's title pill, which is a plain LiquidButton β€” lighter blur, shallower lens, and the + // library's default highlight/shadow rather than the gravity-angled app-chrome ones. + .then( + if (viewerChrome) { + Modifier.viewerGlass(backdrop, containerColor, shape = { Capsule }) + } else { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { Capsule }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + highlight = { + Highlight(style = HighlightStyle.Default(angle = uiSensor.gravityAngle, falloff = 2f)) + }, + shadow = { Shadow(radius = 6f.dp, color = Color.Black.copy(alpha = 0.08f)) }, + innerShadow = { InnerShadow(radius = 2f.dp, alpha = 0.25f) }, + onDrawSurface = { drawRect(containerColor) } + ) + } + ) + .height(GlassSearchPillHeight) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp) + ) { + Icon(Icons.Rounded.Search, null, Modifier.size(15.dp), sub.copy(0.9f)) + Box(Modifier.weight(1f)) { + if (query.isEmpty()) { + // Portuguese hints run ~1.3x longer than English β€” clip rather than wrap out of a + // 36 dp capsule. + BasicText( + hint, + style = TextStyle(sub.copy(0.75f), 13.sp, FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = TextStyle(text, 13.sp, FontWeight.SemiBold), + cursorBrush = SolidColor(LiquidGlassColors.Blue), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSubmit() }), + modifier = Modifier + .fillMaxWidth() + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + ) + } + trailing?.invoke(this) + if (query.isNotEmpty()) { + Box( + Modifier + .size(20.dp) + .clip(CircleShape) + .background(sub.copy(0.30f)) + .clickable(interactionSource = null, indication = null) { onQueryChange("") }, + contentAlignment = Alignment.Center + ) { + CloseCrossIcon(Modifier.size(10.dp), if (isLightTheme) Color.White else Color.Black.copy(0.75f)) + } + } + } +} + +/** + * An uppercase section label, iOS grouped-list style. Sits above a glass panel rather than inside + * it, so the panel stays a single uninterrupted glass surface. + */ +@Composable +fun GlassSectionLabel(text: String, modifier: Modifier = Modifier) { + BasicText( + text.uppercase(), + style = TextStyle( + // Full-weight ink (black in light, white in dark) rather than the grouped-list grey β€” + // the Tools section headers (Organize / Convert / Edit / Optimize / Secure) read as + // proper titles this way instead of fading into the wallpaper. + color = LiquidGlassColors.text(LocalIsDarkMode.current), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.8.sp + ), + modifier = modifier.padding(start = 6.dp, bottom = 8.dp) + ) +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassTitlePill.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassTitlePill.kt new file mode 100644 index 0000000..7600838 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/GlassTitlePill.kt @@ -0,0 +1,120 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.unit.dp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.kyant.backdrop.Backdrop + +/** + * Drop-in replacement for a `LiquidGlassTopBar` sitting in a header [Row]: it takes the remaining + * width, centres a [GlassTitlePill] in it, and adds a 40 dp balancer so the pill is optically + * centred against the leading back button. Every tool screen's header is this shape. + */ +@Composable +fun RowScope.GlassScreenTitle( + title: String, + backdrop: Backdrop, + fontFamily: FontFamily? = null +) { + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + GlassTitlePill(text = title, backdrop = backdrop, fontFamily = fontFamily) + } + Spacer(Modifier.size(40.dp)) +} + +/** + * The title pill used across the app β€” the *same* widget as the PDF viewer's "Page 1 / 12" and the + * spreadsheet viewer's "Sheet 1 / 3" chip (`PdfViewerScreen.kt:778`): a [LiquidButton] capsule at its + * default 48 dp height and 16 dp horizontal padding, with 13 sp SemiBold label. Only the text differs. + * + * It keeps [LiquidButton]'s interactive press deformation, so the pill squashes and refracts under a + * finger exactly like the viewer's does, and it springs in on first composition with a soft overshoot. + * + * The surface defaults to the theme's glass tint rather than the viewer's fixed dark chrome, so the + * pill stays legible on Home and Tools in light mode. + */ +@Composable +fun GlassTitlePill( + text: String, + backdrop: Backdrop, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + surfaceColor: Color = Color.Unspecified, + /** + * Overrides the theme ink. Only the viewers need this: their chrome is picked from the *page's* + * luminance rather than the app theme, so on a white page in dark mode the label has to go dark + * while the rest of the app stays light-on-dark. + */ + contentColor: Color = Color.Unspecified, + /** Only the typeface may vary β€” size and weight stay at the viewer's 13 sp SemiBold. */ + fontFamily: FontFamily? = null, + animateIn: Boolean = true +) { + val isDarkMode = LocalIsDarkMode.current + val resolvedSurface = if (surfaceColor.isSpecified) { + surfaceColor + } else { + if (isDarkMode) Color(0xFF1E1E1E).copy(0.35f) else Color(0xFFFAFAFA).copy(0.35f) + } + val resolvedInk = if (contentColor.isSpecified) contentColor else LiquidGlassColors.text(isDarkMode) + + var shown by remember { mutableStateOf(!animateIn) } + LaunchedEffect(Unit) { shown = true } + val scale by animateFloatAsState( + if (shown) 1f else 0.9f, + spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow), + label = "titlePillScale" + ) + val alpha by animateFloatAsState( + if (shown) 1f else 0f, + spring(dampingRatio = 1f, stiffness = Spring.StiffnessMedium), + label = "titlePillAlpha" + ) + + LiquidButton( + onClick = onClick ?: {}, + backdrop = backdrop, + surfaceColor = resolvedSurface, + modifier = modifier.graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + } + ) { + BasicText( + text, + style = TextStyle( + color = resolvedInk, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = fontFamily + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt index 24fbc4c..f1da65e 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt @@ -169,7 +169,12 @@ fun LiquidBottomTabs( shape = { Capsule }, effects = { vibrancy() - blur(8f.dp.toPx()) + // Half the original 8 dp. This is the tab bar's own surface only β€” the + // selection capsule below draws through `tabsBackdrop`, which keeps its + // own blur, so softening the bar does not soften the slider riding on it. + // The lens is untouched: at 24x24 it is what gives the bar its edge, and + // dropping the blur without it would flatten the whole capsule. + blur(4f.dp.toPx()) lens(24f.dp.toPx(), 24f.dp.toPx()) }, layerBlock = { @@ -274,12 +279,16 @@ fun LiquidBottomTabs( }, onDrawSurface = { val progress = dampedDragAnimation.pressProgress + // A bright, frosted-glass highlight (not a dark pasted-on pill): a soft + // white sheen with a whisper of the accent so the selected tab reads as a + // lit capsule of the same glass, not a separate dark chip. drawRect( - if (isLightTheme) Color.Black.copy(0.1f) - else Color.White.copy(0.1f), + if (isLightTheme) Color.White.copy(0.55f) + else Color.White.copy(0.14f), alpha = 1f - progress ) - drawRect(Color.Black.copy(alpha = 0.03f * progress)) + drawRect(accentColor.copy(alpha = if (isLightTheme) 0.10f else 0.16f), alpha = 1f - progress) + drawRect(Color.White.copy(alpha = 0.04f * progress)) } ) .height(56f.dp) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidGlassPanel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidGlassPanel.kt index c111544..e0330ca 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidGlassPanel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidGlassPanel.kt @@ -17,14 +17,23 @@ import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.Shadow import com.kyant.shapes.RoundedRectangle +/** Dark, mostly-opaque glass base for PDF-viewer chrome so white text stays readable + * over bright pages, while the lens/blur/highlight refraction is preserved. */ +val ViewerChromeGlass: Color = Color(0xFF12151C).copy(alpha = 0.62f) + @Composable fun Modifier.liquidGlassPanel( backdrop: Backdrop, - uiSensor: UISensor + uiSensor: UISensor, + // When set, overrides the theme-based tint. Used by the PDF viewer chrome, which + // renders white text over a backdrop that may be a bright page β€” it needs a dark, + // mostly-opaque base so text stays readable while the glass refraction is kept. + containerColorOverride: Color? = null ): Modifier { val isDarkMode = LocalIsDarkMode.current val isLightTheme = !isDarkMode - val containerColor = if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f) else Color(0xFF1E1E1E).copy(0.4f) + val containerColor = containerColorOverride + ?: if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f) else Color(0xFF1E1E1E).copy(0.4f) return this.drawBackdrop( backdrop = backdrop, shape = { RoundedRectangle(28f.dp) }, diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidIconButton.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidIconButton.kt index 35f47db..b70ae4d 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidIconButton.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidIconButton.kt @@ -1,6 +1,8 @@ package com.chethan616.clearpdf.ui.components +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -31,6 +33,7 @@ import kotlin.math.tanh /** * Circular variant of LiquidButton, optimized for icons. */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun LiquidIconButton( onClick: () -> Unit, @@ -39,6 +42,7 @@ fun LiquidIconButton( isInteractive: Boolean = true, tint: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified, + onLongClick: (() -> Unit)? = null, content: @Composable () -> Unit ) { val animationScope = rememberCoroutineScope() @@ -95,11 +99,22 @@ fun LiquidIconButton( } } ) - .clickable( - interactionSource = null, - indication = null, - role = Role.Button, - onClick = onClick + .then( + if (onLongClick != null) + Modifier.combinedClickable( + interactionSource = null, + indication = null, + role = Role.Button, + onClick = onClick, + onLongClick = onLongClick + ) + else + Modifier.clickable( + interactionSource = null, + indication = null, + role = Role.Button, + onClick = onClick + ) ) .then( if (isInteractive) { diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidSaveDialog.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidSaveDialog.kt index 4773c55..2165374 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidSaveDialog.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidSaveDialog.kt @@ -3,7 +3,17 @@ package com.chethan616.clearpdf.ui.components import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText @@ -18,10 +28,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -32,6 +44,11 @@ import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.UISensor import com.kyant.backdrop.Backdrop +/** + * Solid modal presentation β€” used on the tool screens (they sit over the wallpaper, and a + * Dialog is a separate window that cannot sample the page anyway, so a clean themed card + * beats a wallpaper-PNG "glass" look). + */ @Composable fun LiquidSaveDialog( initialFileName: String, @@ -39,25 +56,116 @@ fun LiquidSaveDialog( uiSensor: UISensor, onDismiss: () -> Unit, onSave: (fileName: String, locationUri: Uri?) -> Unit +) { + val isLight = !LocalIsDarkMode.current + // Same adaptive palette the in-window sheet uses, derived from the theme (tool screens + // sit over the wallpaper, so we can't sample a live page β€” a solid card matches the theme). + val fg = if (isLight) Color(0xFF15171C) else Color.White + val fgSoft = if (isLight) Color(0xFF15171C).copy(0.62f) else Color.White.copy(0.62f) + val field = if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.10f) + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + SaveDocumentBody( + surface = Modifier + .fillMaxWidth(0.9f) + .widthIn(max = 440.dp) + .clip(RoundedCornerShape(28.dp)) + .background(if (isLight) Color(0xFFF5F6F8) else Color(0xFF1B1E25)) + .border(1.dp, if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.12f), RoundedCornerShape(28.dp)), + initialFileName = initialFileName, + backdrop = backdrop, + fg = fg, + fgSoft = fgSoft, + field = field, + onDismiss = onDismiss, + onSave = onSave + ) + } +} + +/** + * Real, in-window Liquid-Glass sheet. Rendered inside the screen (NOT a Dialog window) so the + * glass panel samples the actual content behind it (e.g. the live PDF page) β€” no PNG, no solid + * slab. Enters with a scrim + gentle glass "pop". + */ +@Composable +fun LiquidSaveSheet( + visible: Boolean, + initialFileName: String, + backdrop: Backdrop, + uiSensor: UISensor, + // Adaptive chrome palette from the viewer (matches the Insert Text dialog exactly). + fg: Color, + fgSoft: Color, + surface: Color, + field: Color, + onDismiss: () -> Unit, + onSave: (fileName: String, locationUri: Uri?) -> Unit +) { + Box(Modifier.fillMaxSize()) { + // Fade-only (like AnnotationEditorDialog) β€” glass never re-blurs mid-transition. + AnimatedVisibility(visible, enter = fadeIn(tween(200)), exit = fadeOut(tween(160)), modifier = Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(0.45f)) + .pointerInput(Unit) { detectTapGestures { onDismiss() } } + ) + } + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(140)), + modifier = Modifier.align(Alignment.Center).imePadding() + ) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + SaveDocumentBody( + surface = Modifier + .fillMaxWidth(0.9f) + .widthIn(max = 440.dp) + .liquidGlassPanel(backdrop, uiSensor, surface), + initialFileName = initialFileName, + backdrop = backdrop, + fg = fg, + fgSoft = fgSoft, + field = field, + onDismiss = onDismiss, + onSave = onSave + ) + } + } + } +} + +/** + * Shared save UI β€” visually a member of the same dialog family as the Insert Text editor + * ([AnnotationEditorDialog]): same 18dp padding, 14dp spacing, 16sp Bold title, field boxes + * (heightIn 54 / radius 12 / [field] bg / 12Γ—10 padding), and LiquidButton footer. The + * presentation (solid card vs glass panel) is passed in via [surface]; colours via [fg]/[fgSoft]/[field]. + */ +@Composable +private fun SaveDocumentBody( + surface: Modifier, + initialFileName: String, + backdrop: Backdrop, + fg: Color, + fgSoft: Color, + field: Color, + onDismiss: () -> Unit, + onSave: (fileName: String, locationUri: Uri?) -> Unit ) { val context = LocalContext.current - val isDarkMode = LocalIsDarkMode.current - val isLight = !isDarkMode - val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) - val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) val accent = Color(0xFF1976D2) + // Soft violet accent for the "choose location" affordance β€” a contextual, important action. + val folderAccent = Color(0xFF7C5CFF) var fileName by remember { mutableStateOf(initialFileName) } var locationUri by remember { mutableStateOf(SaveLocationManager.getSaveUri(context)) } var locationDisplay by remember { mutableStateOf(SaveLocationManager.getSavePathDisplay(context)) } - val folderPicker = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocumentTree() - ) { uri: Uri? -> + val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> if (uri != null) { try { - val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or - android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION context.contentResolver.takePersistableUriPermission(uri, flags) } catch (_: Exception) {} locationUri = uri @@ -65,93 +173,66 @@ fun LiquidSaveDialog( } } - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Column( - modifier = Modifier - .fillMaxWidth(0.9f) - .liquidGlassPanel(backdrop, uiSensor) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - BasicText( - stringResource(R.string.save_document), - style = TextStyle(color = text, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) - ) + Column(surface.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + BasicText(stringResource(R.string.save_document), style = TextStyle(fg, 16.sp, FontWeight.Bold)) - // File Name Input - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - BasicText(stringResource(R.string.file_name), style = TextStyle(color = sub, fontSize = 13.sp, fontWeight = FontWeight.Medium)) + // File name β€” same field treatment as the Insert Text dialog's text box. + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + BasicText(stringResource(R.string.file_name), style = TextStyle(fgSoft, 12.sp, FontWeight.Medium)) + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 54.dp) + .clip(RoundedCornerShape(12.dp)) + .background(field) + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.CenterStart + ) { + if (fileName.isEmpty()) BasicText(stringResource(R.string.document_pdf), style = TextStyle(fgSoft, 14.sp)) BasicTextField( value = fileName, onValueChange = { fileName = it }, - textStyle = TextStyle(color = text, fontSize = 16.sp), + textStyle = TextStyle(fg, 14.sp), cursorBrush = SolidColor(accent), - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(if (isLight) Color.Black.copy(0.04f) else Color.White.copy(0.08f)) - .padding(14.dp), - decorationBox = { inner -> - if (fileName.isEmpty()) { - BasicText(stringResource(R.string.document_pdf), style = TextStyle(color = sub.copy(0.5f), fontSize = 16.sp)) - } - inner() - } + singleLine = true, + modifier = Modifier.fillMaxWidth() ) } + } - // Save Location Picker - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - BasicText(stringResource(R.string.settings_save_location), style = TextStyle(color = sub, fontSize = 13.sp, fontWeight = FontWeight.Medium)) - LiquidButton( - onClick = { folderPicker.launch(null) }, - backdrop = backdrop, - surfaceColor = if (isLight) Color.Black.copy(0.04f) else Color.White.copy(0.08f), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(vertical = 4.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - BasicText(locationDisplay, style = TextStyle(color = text, fontSize = 14.sp), maxLines = 1) - } - Spacer(Modifier.width(8.dp)) - Icon(Icons.Rounded.FolderOpen, stringResource(R.string.change_folder), Modifier.size(20.dp), accent) + // Save location β€” a stronger, violet-tinted contextual action card. + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + BasicText(stringResource(R.string.settings_save_location), style = TextStyle(fgSoft, 12.sp, FontWeight.Medium)) + LiquidButton(onClick = { folderPicker.launch(null) }, backdrop = backdrop, surfaceColor = folderAccent.copy(0.16f), modifier = Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(34.dp).clip(RoundedCornerShape(10.dp)).background(folderAccent.copy(0.22f)), + contentAlignment = Alignment.Center + ) { Icon(Icons.Rounded.FolderOpen, null, Modifier.size(18.dp), folderAccent) } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + BasicText(stringResource(R.string.change_folder), style = TextStyle(fgSoft, 11.sp, FontWeight.Medium), maxLines = 1, overflow = TextOverflow.Ellipsis) + BasicText(locationDisplay, style = TextStyle(fg, 14.sp, FontWeight.SemiBold), maxLines = 1, overflow = TextOverflow.Ellipsis) } } } + } - // Actions - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically + // Actions β€” same footer as the Insert Text dialog (field Cancel + blue Save). + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), verticalAlignment = Alignment.CenterVertically) { + LiquidButton(onClick = onDismiss, backdrop = backdrop, surfaceColor = field) { + BasicText(stringResource(R.string.cancel), style = TextStyle(fg, 13.sp, FontWeight.Medium)) + } + LiquidButton( + onClick = { + val finalName = fileName.ifBlank { "Document" }.let { if (!it.lowercase().endsWith(".pdf")) "$it.pdf" else it } + onSave(finalName, locationUri) + }, + backdrop = backdrop, + tint = accent ) { - LiquidButton( - onClick = onDismiss, - backdrop = backdrop, - surfaceColor = Color.Transparent - ) { - BasicText(stringResource(R.string.cancel), style = TextStyle(color = sub, fontSize = 14.sp, fontWeight = FontWeight.Medium)) - } - Spacer(Modifier.width(8.dp)) - LiquidButton( - onClick = { - val finalName = fileName.ifBlank { "Document" }.let { if (!it.lowercase().endsWith(".pdf")) "$it.pdf" else it } - onSave(finalName, locationUri) - }, - backdrop = backdrop, - tint = accent - ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Rounded.Save, stringResource(R.string.save), Modifier.size(16.dp), Color.White) - BasicText(stringResource(R.string.save), style = TextStyle(color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)) - } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Save, null, Modifier.size(15.dp), Color.White) + BasicText(stringResource(R.string.save), style = TextStyle(Color.White, 13.sp, FontWeight.Bold), maxLines = 1) } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt new file mode 100644 index 0000000..da2ec5f --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/OnboardingDemos.kt @@ -0,0 +1,687 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.ui.graphics.lerp as lerpColor +import androidx.compose.foundation.background +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.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.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.GridOn +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.PictureAsPdf +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Slideshow +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +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 androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.kyant.backdrop.Backdrop +import com.kyant.shapes.Capsule +import com.kyant.shapes.RoundedRectangle +import kotlinx.coroutines.delay +import kotlin.math.cos +import kotlin.math.sin + +/** + * The live animation replays that sit on the onboarding pages. + * + * Every one of these is a **sibling** of a real component, never a modification of it. The app's + * production animations are gesture-driven β€” `ShareMorphButton` morphs off a long-press, + * `GlassSearchHeader` off a tap β€” and bolting a "demo mode" parameter onto a shipping component so + * onboarding can puppet it is how those components rot. Instead each replay re-uses the *same* + * [GlassMotion] spec and the same easing curves as the thing it is teaching, so the motion is + * genuinely identical even though the code is separate. The one exception is [DemoToolsMenu], which + * drives the real [GlassCapsuleMenu] unmodified because that component already exposes a + * caller-owned `progress`. + * + * **Every replay takes `isActive`.** `HorizontalPager` composes the pages either side of the current + * one, so without gating each loop on visibility all five demos run at once, off-screen, forever. + */ + +/** Matches ToolsScreen's tile entrance β€” a real overshoot, on scale only. */ +private val EaseOutBack = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) + +/** One file kind: the label page 3 prints on its chips, and the colour both pages carry. */ +private data class DemoKind(val label: String, val tint: Color) + +/** + * The file-kind palette, shared by page 1's orbiting sheets and page 3's chip grid so the two pages + * teach the same colour language rather than each inventing its own. Order follows `DocKind`'s + * (`utils/DocKind.kt`), and the length is deliberately [OrbitPages] β€” page 1 maps one kind per sheet + * by index, so the two must stay the same size. + */ +private val DemoKinds = listOf( + DemoKind("PDF", LiquidGlassColors.Red), + DemoKind("DOC", LiquidGlassColors.Blue), + DemoKind("XLS", LiquidGlassColors.Green), + DemoKind("PPT", LiquidGlassColors.Orange), + DemoKind("IMG", LiquidGlassColors.Purple), + DemoKind("TXT", LiquidGlassColors.Teal) +) + +/** + * Drives a demo loop: returns a 0..1 float that ramps over [riseMs], holds, resets, and repeats, + * but only while [isActive]. + * + * Uses a keyed `LaunchedEffect` + `animateFloatAsState` rather than `rememberInfiniteTransition` + * because these replays need a **hold** at the end of each cycle β€” the user has to actually see the + * finished state before it rewinds. An infinite transition with `RepeatMode.Restart` snaps back with + * no dwell, which reads as a stutter rather than a demonstration. + */ +@Composable +private fun rememberDemoLoop( + isActive: Boolean, + riseMs: Int = 620, + holdMs: Long = 1500L, + gapMs: Long = 420L +): Float { + var target by remember { mutableFloatStateOf(0f) } + LaunchedEffect(isActive) { + if (!isActive) { target = 0f; return@LaunchedEffect } + // Small lead-in so the page has settled before the demo starts playing. + delay(280) + while (true) { + target = 1f + delay(riseMs + holdMs) + target = 0f + delay(gapMs) + } + } + val v by animateFloatAsState(target, tween(riseMs, easing = FastOutSlowInEasing), label = "demoLoop") + return v +} + +// ── Page 1 Β· Welcome ──────────────────────────────────────────────────────────────────────────── + +/** Sheets in the orbit. Six reads as "a document's worth" without the centre turning to mush. */ +private const val OrbitPages = 6 + +/** Radius of the loose orbit, in dp, before the vortex takes over. */ +private const val OrbitRadius = 74f + +private const val TwoPi = 6.2831855f +private const val Pi = 3.1415927f + +/** Footprint of a single sheet, and therefore of the assembled book. */ +private val SheetW = 122.dp +private val SheetH = 158.dp + +/** + * Smoothstep. + * + * Used in place of an `Easing` object because every phase below is a *slice* of one linear clock + * that each sheet re-maps for itself β€” an `Easing` would have to be re-transformed per sheet anyway, + * and as a plain function the whole trajectory stays one readable expression. + */ +private fun smooth(t: Float): Float { + val x = t.coerceIn(0f, 1f) + return x * x * (3f - 2f * x) +} + +/** How far the assembled book has taken over from the last sheet to land. */ +private fun bookIn(p: Float): Float = ((p - 0.82f) / 0.15f).coerceIn(0f, 1f) + +/** + * Loose pages orbiting the centre, drawn into a vortex, folding as they fall, and settling into a + * book. + * + * **The spiral is emergent, not scripted.** There is not a single control point or keyframed path + * here: each sheet's radius collapses while its angular speed *climbs* (`pullΒ²`), and those two + * together are what bends the trajectory inward. A constant sweep against a shrinking radius reads + * as a straight run at the centre; accelerating the sweep is what makes it curve. It also means the + * paths never cross the same way twice as the stagger shifts them, which is why the motion reads as + * organic rather than as six objects on rails. + * + * **Each sheet runs the same shape on its own delayed clock.** Without the per-index `head` offset + * all six arrive as one clump; with it the deck assembles a sheet at a time, and `depth` doubles as + * both the landing order and the fan offset in the settled stack, so the first to arrive ends up + * furthest back. + * + * The fold is `rotationY`, not a `scaleX` squeeze β€” the near edge has to actually foreshorten or it + * reads as a card being squashed rather than paper being creased. `cameraDistance` is pulled in + * tight to exaggerate that at this size. + * + * **Only the book is glass.** Six orbiting glass surfaces would mean six blur-and-lens passes per + * frame; the flying sheets are flat fills moved by `graphicsLayer`, which costs nothing, and the one + * glass surface arrives at the end, stationary, cross-faded over the top sheet it replaces. + */ +@Composable +fun DemoDocumentOpen(isActive: Boolean, backdrop: Backdrop, glass: Color, ink: Color) { + val progress = remember { Animatable(0f) } + val fade = remember { Animatable(0f) } + + // Hand-rolled rather than [rememberDemoLoop]: this demo needs a LINEAR clock, because every + // curve above is carved out of it by hand and a second easing on top would double-ease them. + // + // It plays ONCE and holds on the finished book β€” no rewind loop. Looping it would play the + // assembly in reverse (the book exploding back into an orbit), and the ask is a single, settled + // demonstration. It re-arms only when the page is left and re-entered, which this effect's + // `isActive` key already gives for free. + LaunchedEffect(isActive) { + if (!isActive) { fade.snapTo(0f); progress.snapTo(0f); return@LaunchedEffect } + delay(240) + fade.animateTo(1f, tween(300, easing = FastOutSlowInEasing)) + progress.animateTo(1f, tween(2200, easing = LinearEasing)) + } + + // The assembled book stays neutral glass so the CONTENT can carry the colour: each of its lines + // is tinted by one of the six formats that flew in, in the same order they landed, so the book + // visibly reads as "made of" the PDF/DOC/XLS/PPT/IMG/TXT sheets that assembled it rather than + // collapsing to a single red. + val bookGlass = glass + + Box( + Modifier + .size(224.dp) + .graphicsLayer { alpha = fade.value }, + contentAlignment = Alignment.Center + ) { + repeat(OrbitPages) { i -> + val depth = (OrbitPages - 1 - i).toFloat() + val base = (i.toFloat() / OrbitPages) * TwoPi + // Alternating fold direction, so the deck does not crease as one slab. + val dir = if (i % 2 == 0) 1f else -1f + + OrbitSheet( + // One kind per sheet, in `DemoKinds` order β€” so the six formats the app opens fly in + // as themselves and assemble into the one reader, and page 3's chip grid later + // repeats the same colours for the same formats. + tint = DemoKinds[i].tint, + modifier = Modifier.graphicsLayer { + val p = progress.value + val head = i * 0.045f + val local = ((p - head) / (1f - head)).coerceIn(0f, 1f) + // Orbit through the opening stretch, in-fall through the middle, settled after. + val pull = smooth(((local - 0.18f) / 0.58f).coerceIn(0f, 1f)) + + val angle = base + (0.40f * local + 1.15f * pull * pull) * TwoPi + val radius = OrbitRadius * (1f - pull) * density + + translationX = cos(angle) * radius + (depth * 2.4f * density) * pull + translationY = sin(angle) * radius + (-depth * 2.8f * density) * pull + + // Peaks mid-flight and relaxes: creased on the way in, flat on landing. + val fold = sin(pull * Pi) + rotationY = 64f * fold * dir + rotationZ = lerp(sin(angle) * 17f, depth * 1.7f, pull) + 22f * fold * dir + cameraDistance = 14f + + val s = lerp(0.32f, 1f, pull) + scaleX = s + scaleY = s + // The top sheet is the one the glass book replaces, so it hands over rather + // than sitting underneath and darkening it. + alpha = smooth(local * 4f) * + if (i == OrbitPages - 1) 1f - bookIn(p) else 1f + } + ) + } + + Box( + Modifier + .size(width = SheetW, height = SheetH) + .graphicsLayer { + val b = bookIn(progress.value) + alpha = b + val s = lerp(0.97f, 1f, b) + scaleX = s + scaleY = s + } + // withShadow = false: this book is the one surface on page 1, and its drop shadow used + // to snap in the instant the assembled book reached full opacity. Page 1 shows no + // shadow at all now β€” see [viewerGlass]'s `withShadow`. + .viewerGlass(backdrop, bookGlass, shape = { RoundedRectangle(16f.dp) }, withShadow = false) + .padding(horizontal = 15.dp, vertical = 17.dp), + contentAlignment = Alignment.TopStart + ) { + // Content arrives once the book has: the last beat, so the sequence ends on something + // legible rather than on the stack merely stopping. Each line wears the colour of the + // format sheet at its position β€” in [DemoKinds] order β€” so the finished book carries all + // six colours that formed it, laid down one after another. + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + val widths = listOf(0.85f, 1f, 0.72f, 0.94f, 0.6f, 0.88f, 0.45f) + widths.forEachIndexed { idx, w -> + val lineTint = DemoKinds[idx % DemoKinds.size].tint + Box( + Modifier + .fillMaxWidth(w) + .height(if (idx == 0) 8.dp else 5.dp) + .graphicsLayer { + val head = idx * 0.10f + val t = ((bookIn(progress.value) - head) / (1f - head)) + .coerceIn(0f, 1f) + alpha = t + // Grows from the left edge, like a line of text being laid down. + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) + scaleX = t + } + .clip(RoundedCornerShape(3.dp)) + .background(lineTint.copy(if (idx == 0) 0.85f else 0.62f)) + ) + } + } + } + } +} + +/** + * One flying page. Flat by design β€” see [DemoDocumentOpen]; the bars are what make a rounded + * rectangle read as a document once it is down at a third of its size. + * + * [tint] is the sheet's file kind, from [DemoKinds]. Card and bars share it so a sheet reads as one + * coloured object rather than a coloured card with grey lines on it. Both alphas sit a little above + * the neutral ones they replace (0.16 / 0.24): a hue needs more weight than grey to register at the + * 0.32 flight scale, under a 64Β° fold. + */ +@Composable +private fun OrbitSheet(tint: Color, modifier: Modifier) { + Box( + modifier + .size(width = SheetW, height = SheetH) + .clip(RoundedCornerShape(14.dp)) + .background(tint.copy(0.20f)) + .padding(horizontal = 14.dp, vertical = 16.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + listOf(0.80f, 1f, 0.66f, 0.92f, 0.50f).forEach { w -> + Box( + Modifier + .fillMaxWidth(w) + .height(5.dp) + .clip(RoundedCornerShape(3.dp)) + .background(tint.copy(0.34f)) + ) + } + } + } +} + +// ── Page 3 Β· Open anything ────────────────────────────────────────────────────────────────────── + +/** + * "Open anything" as ONE liquid-glass document that becomes each format in turn, rather than a static + * list of rows. A single [viewerGlass] card holds a big format icon, the format name and its + * extensions, and a few tinted "content" lines β€” and it cross-fades from PDF β†’ Word β†’ Excel β†’ PPT β†’ + * Image on a loop, the whole card re-tinting toward each format's colour as it goes. It reads as the + * same reader opening anything you hand it, which is the promise the page is making, and it does it + * with the app's real glass instead of a generic chip grid. + * + * One glass surface (not five), so it costs a single blur+lens pass; only the flat inner content + * cross-fades. Names reuse the localized recents category strings; extensions are literal. + */ +@Composable +fun DemoFileKinds(isActive: Boolean, backdrop: Backdrop, glass: Color, ink: Color, inkSoft: Color) { + data class KindCard(val icon: ImageVector, val tint: Color, val name: String, val ext: String) + val cards = listOf( + KindCard(Icons.Rounded.PictureAsPdf, LiquidGlassColors.Red, stringResource(R.string.recents_filter_pdf), ".pdf"), + KindCard(Icons.Rounded.Description, LiquidGlassColors.Blue, stringResource(R.string.recents_filter_word), ".doc Β· .docx"), + KindCard(Icons.Rounded.GridOn, LiquidGlassColors.Green, stringResource(R.string.recents_filter_excel), ".xls Β· .xlsx"), + KindCard(Icons.Rounded.Slideshow, LiquidGlassColors.Orange, stringResource(R.string.recents_filter_ppt), ".ppt Β· .pptx"), + KindCard(Icons.Rounded.Image, LiquidGlassColors.Purple, stringResource(R.string.recents_filter_image), ".jpg Β· .png") + ) + + // Advance the format on a dwell loop; reset to the first when the page is left so it always opens + // on PDF. Gated on `isActive` like every other demo β€” see the file header. + var index by remember { mutableIntStateOf(0) } + LaunchedEffect(isActive) { + if (!isActive) { index = 0; return@LaunchedEffect } + delay(500) + while (true) { + delay(1500) + index = (index + 1) % cards.size + } + } + val current = cards[index] + + // The card tint eases toward the active format's colour, so the glass itself carries the change + // even between the content cross-fades. A soft blend, so it stays glass rather than a colour swatch. + val cardTint by animateColorAsState( + lerpColor(glass, current.tint, 0.16f), + tween(560, easing = FastOutSlowInEasing), + label = "fileCardTint" + ) + + Column( + Modifier + .width(212.dp) + .viewerGlass(backdrop, cardTint, shape = { RoundedRectangle(26f.dp) }) + .padding(22.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // The whole inner content cross-fades as one, so the icon, the name and the tinted lines all + // turn over together. The glass card behind is outside the fade and never re-measures. + Crossfade( + targetState = index, + animationSpec = tween(460, easing = FastOutSlowInEasing), + label = "fileKind" + ) { i -> + val k = cards[i] + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + Modifier + .size(76.dp) + .clip(RoundedCornerShape(22.dp)) + .background(k.tint.copy(0.92f)), + contentAlignment = Alignment.Center + ) { + Icon(k.icon, null, Modifier.size(40.dp), Color.White) + } + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + BasicText( + k.name, + style = TextStyle(ink, 19.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + ) + BasicText( + k.ext, + style = TextStyle(inkSoft, 13.sp, textAlign = TextAlign.Center), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + // A few "content" lines in the format's own colour, so the card reads as a document of + // that kind rather than just an icon. + Column( + Modifier.fillMaxWidth().padding(top = 2.dp), + verticalArrangement = Arrangement.spacedBy(7.dp) + ) { + listOf(1f, 0.82f, 0.92f, 0.6f).forEach { w -> + Box( + Modifier + .fillMaxWidth(w) + .height(5.dp) + .clip(RoundedCornerShape(3.dp)) + .background(k.tint.copy(0.45f)) + ) + } + } + } + } + } +} + +// ── Page 5 Β· The feature slots ────────────────────────────────────────────────────────────────── + +/** + * Height of a feature row's leading demo slot. + * + * Kept modest on purpose. The features page is the tallest in the flow, and the demo area is a + * `weight(1f)` box between fixed top and bottom padding β€” on a 640 dp-tall phone that box is only + * around 280 dp, and the panel has to fit inside it or it overlaps the copy underneath. + */ +private val SlotHeight = 50.dp + +/** + * A pen stroke drawing itself across a mini page, then a highlighter sweeping under it. + * + * The reveal is a [PathMeasure] trim rather than an animated point list: sampling `getSegment` gives + * a stroke that grows at a constant *arc-length* rate, so it draws at an even speed through the + * curve. Interpolating the control points instead makes it visibly rush the straight sections. + */ +@Composable +fun DemoAnnotate(isActive: Boolean, ink: Color) { + val p = rememberDemoLoop(isActive, riseMs = 900, holdMs = 1100L) + + Box( + Modifier + .size(width = 72.dp, height = SlotHeight) + .clip(RoundedCornerShape(10.dp)) + .background(ink.copy(0.10f)) + ) { + androidx.compose.foundation.Canvas(Modifier.fillMaxSize().padding(8.dp)) { + val w = size.width + val h = size.height + + // Two text lines for the stroke to sit against. + listOf(0.24f, 0.52f).forEach { fy -> + drawLine( + ink.copy(0.30f), + Offset(0f, h * fy), + Offset(w * 0.92f, h * fy), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round + ) + } + + // The highlighter sweeps first, under the signature β€” same order a user works in. + val hl = (p / 0.45f).coerceIn(0f, 1f) + if (hl > 0f) { + drawLine( + Color(0xFFFFD60A).copy(0.55f), + Offset(0f, h * 0.24f), + Offset(w * 0.92f * hl, h * 0.24f), + strokeWidth = 9.dp.toPx(), + cap = StrokeCap.Butt + ) + } + + // A signature-ish squiggle, trimmed by arc length. + val sig = ((p - 0.35f) / 0.65f).coerceIn(0f, 1f) + if (sig > 0f) { + val path = Path().apply { + moveTo(w * 0.05f, h * 0.86f) + cubicTo(w * 0.20f, h * 0.60f, w * 0.28f, h * 1.02f, w * 0.44f, h * 0.80f) + cubicTo(w * 0.58f, h * 0.62f, w * 0.62f, h * 0.98f, w * 0.78f, h * 0.76f) + cubicTo(w * 0.86f, h * 0.66f, w * 0.90f, h * 0.80f, w * 0.95f, h * 0.72f) + } + val measure = PathMeasure().apply { setPath(path, false) } + val drawn = Path() + measure.getSegment(0f, measure.length * sig, drawn, true) + drawPath( + drawn, + Color(0xFF0A84FF), + style = Stroke(width = 2.4f.dp.toPx(), cap = StrokeCap.Round) + ) + } + } + } +} + +/** + * The search capsule unfurling out of its circle, replaying `GlassSearchHeader`'s open. + * + * Keeps that component's split exactly: the **width** is critically damped ([GlassMotion.settle]'s + * shape, via a linear-ish ease here since it drives real layout) while the glyph's quarter-turn and + * the field's scale ride an overshooting curve. A bouncing width would re-measure every frame and + * re-run the blur underneath. + */ +@Composable +fun DemoSearch(isActive: Boolean, backdrop: Backdrop, glass: Color, ink: Color) { + val p = rememberDemoLoop(isActive, riseMs = 620, holdMs = 1400L) + val eased = FastOutSlowInEasing.transform(p) + val overshoot = EaseOutBack.transform(p) + + Box(Modifier.size(width = 72.dp, height = SlotHeight), contentAlignment = Alignment.CenterEnd) { + Box( + Modifier + // Layout width: eased, never overshooting. A capsule that sprang past its target + // would drive this negative-adjacent and re-measure the glass every frame. + .width(lerp(34f, 72f, eased).dp) + .height(34.dp) + .viewerGlass(backdrop, glass, shape = { Capsule }), + contentAlignment = Alignment.CenterStart + ) { + Icon( + Icons.Rounded.Search, + null, + Modifier + .padding(start = 9.dp) + .size(16.dp) + // Draw-time property, so this is where the bounce is allowed to live. + .graphicsLayer { rotationZ = lerp(0f, 90f, overshoot) }, + ink.copy(0.75f) + ) + // A caret and a "typed" bar appear once the capsule has room for them. + Box( + Modifier + .padding(start = 32.dp) + .graphicsLayer { + alpha = ((p - 0.45f) / 0.55f).coerceIn(0f, 1f) + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) + scaleX = ((p - 0.45f) / 0.55f).coerceIn(0f, 1f) + } + .width(26.dp) + .height(5.dp) + .clip(RoundedCornerShape(3.dp)) + .background(ink.copy(0.45f)) + ) + } + } +} + +/** + * The real [GlassCapsuleMenu], driven by a looping progress float. + * + * No replay needed and no new drawing code: the component already takes `progress` from its caller + * precisely so whatever opened it can own the clock, which makes it the one production animation + * onboarding can show *literally* rather than by imitation. + * + * The rise is deliberately slower than the real menu's. Its `StaggerFraction` of 0.07 is a fraction + * of `progress`, not a duration, so at the gesture's ~560 ms the three circles land about 39 ms + * apart β€” legible when your own finger caused it, indistinguishable from simultaneous when you are + * watching. Stretching the clock is the only knob that widens the cascade without touching the + * component, and it costs nothing here because nothing is waiting on this animation. + * + * Must be given its natural width. See `OnboardingScreen.FeatureRows` β€” this is a `Row` of 40 dp + * circles, and a constraint narrower than its content silently drops actions rather than shrinking. + */ +@Composable +fun DemoToolsMenu( + isActive: Boolean, + backdrop: Backdrop, + uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, + glass: Color, + actions: List +) { + val p = rememberDemoLoop(isActive, riseMs = 820, holdMs = 1600L) + GlassCapsuleMenu( + actions = actions, + backdrop = backdrop, + uiSensor = uiSensor, + progress = p, + surfaceColor = glass + ) +} + +// ── Page 5 Β· Ready ────────────────────────────────────────────────────────────────────────────── + +/** + * A glass disc springs in, then a checkmark draws itself inside it. + * + * Same [PathMeasure] trim as [DemoAnnotate] β€” the tick is *drawn*, not faded in, which is what makes + * it read as a confirmation rather than an icon appearing. Plays once per visit rather than looping: + * a checkmark that keeps un-checking itself undermines the "you're all set" it is illustrating. + */ +@Composable +fun DemoReady(isActive: Boolean, backdrop: Backdrop, glass: Color) { + var play by remember { mutableIntStateOf(0) } + LaunchedEffect(isActive) { if (isActive) { delay(220); play = 1 } else play = 0 } + + val disc by animateFloatAsState( + if (play == 1) 1f else 0f, + GlassMotion.morph(), + label = "readyDisc" + ) + val tick by animateFloatAsState( + if (play == 1) 1f else 0f, + tween(520, delayMillis = 180, easing = FastOutSlowInEasing), + label = "readyTick" + ) + + Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) { + // A soft accent halo behind the glass, flat, so it can pulse without costing a re-blur. + Box( + Modifier + .size(120.dp) + .graphicsLayer { + val s = lerp(0.7f, 1f, disc.coerceIn(0f, 1f)) + scaleX = s; scaleY = s + alpha = 0.22f * disc.coerceIn(0f, 1f) + } + .clip(CircleShape) + .background(LiquidGlassColors.Green) + ) + Box( + Modifier + .size(96.dp) + .graphicsLayer { + val s = lerp(0.82f, 1f, disc) + scaleX = s; scaleY = s + alpha = disc.coerceIn(0f, 1f) + } + .viewerGlass(backdrop, glass, shape = { Capsule }), + contentAlignment = Alignment.Center + ) { + androidx.compose.foundation.Canvas(Modifier.size(44.dp)) { + if (tick <= 0f) return@Canvas + val w = size.width + val h = size.height + val path = Path().apply { + moveTo(w * 0.16f, h * 0.53f) + lineTo(w * 0.41f, h * 0.76f) + lineTo(w * 0.85f, h * 0.26f) + } + val measure = PathMeasure().apply { setPath(path, false) } + val drawn = Path() + measure.getSegment(0f, measure.length * tick, drawn, true) + drawPath( + drawn, + LiquidGlassColors.Green, + style = Stroke(width = 4.5f.dp.toPx(), cap = StrokeCap.Round) + ) + } + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/ShareMorphButton.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/ShareMorphButton.kt new file mode 100644 index 0000000..f1e197f --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/ShareMorphButton.kt @@ -0,0 +1,260 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.IosShare +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.chethan616.clearpdf.R +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.shapes.Capsule + +/** + * A liquid-glass circle with an Apple-style share gesture: **tap** = [onOpen]; **press-and-hold** + * morphs the circle into a vertical glass capsule, and **swiping up** past a threshold then releasing + * fires [onShare] (an iOS power-slider). Idle capsule is plain glass; it tints blue as the finger + * travels up. Fixed 52dp width; height animates 52β†’104. Meant to be placed as a bottom-anchored + * overlay so the capsule grows strictly UPWARD. + * + * Animation notes: height and both icon layers run off a **single** [updateTransition], so the icon + * swap can't drift away from the morph the way a separate `Crossfade` clock did. The spring is + * deliberately well damped β€” the height drives *layout*, so every overshoot frame re-measures the box + * and forces `drawBackdrop` to re-run its blur and lens at a new size. Damping here is a performance + * fix as much as a visual one. + */ +/** The blue builds on this rather than on the finger's raw travel. See the call site for why. */ +private val TintRamp = CubicBezierEasing(0.45f, 0f, 0.55f, 1f) + +/** + * Ceiling on the blue's opacity, so the armed capsule is still glass and not paint. + * + * Not lower than this. The icons on top are white, and the backdrop here is usually a white PDF + * page β€” every point of transparency lightens the blue toward the page and costs icon contrast. + * 0.82 leaves the refraction clearly visible while landing close to the contrast the old opaque + * fill had. + */ +private const val MaxTintAlpha = 0.82f + +@Composable +fun ShareMorphButton( + backdrop: LayerBackdrop, + glass: Color, + fg: Color, + onOpen: () -> Unit, + onShare: () -> Unit, + onShareModeChanged: (Boolean) -> Unit = {}, + idleIcon: ImageVector = Icons.Rounded.UploadFile, + idleContentDesc: String? = null, + modifier: Modifier = Modifier +) { + var shareMode by remember { mutableStateOf(false) } + var pressed by remember { mutableStateOf(false) } + var dragUp by remember { mutableFloatStateOf(0f) } + val haptics = LocalHapticFeedback.current + val density = LocalDensity.current + val thresholdPx = with(density) { 48.dp.toPx() } + val blue = Color(0xFF0A84FF) + + val morph = updateTransition(shareMode, label = "shareMorph") + // Underdamped: the capsule springs past 104dp and settles back, so the morph has real weight. + val height by morph.animateDp( + transitionSpec = { GlassMotion.morph() }, + label = "shareMorphHeight" + ) { if (it) 104.dp else 52.dp } + // 0 while idle, 1 while morphed. Both icon layers read this, so they cross-fade against the + // same curve the height is travelling on. The fade itself stays critically damped β€” a bouncing + // alpha reads as a flicker β€” while `pop` carries the bounce on scale instead. + val morphed by morph.animateFloat( + transitionSpec = { GlassMotion.fade() }, + label = "shareMorphContent" + ) { if (it) 1f else 0f } + val pop by morph.animateFloat( + transitionSpec = { GlassMotion.pop() }, + label = "shareMorphPop" + ) { if (it) 1f else 0f } + + // A slow bob on the chevron hints "keep going up" while the capsule is open. Only runs while + // morphed, and only touches translation, so it never re-measures the glass. + val bobTransition = rememberInfiniteTransition(label = "shareMorphBob") + val bob by bobTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + tween(900, easing = FastOutSlowInEasing), + RepeatMode.Reverse + ), + label = "shareMorphBobOffset" + ) + + val progress = (-dragUp / thresholdPx).coerceIn(0f, 1f) + // A symmetric S-curve, not [FastOutSlowInEasing]. Fast-out-slow-in front-loads β€” it is already + // past 0.9 at 80% of the travel β€” so the blue rushed in over the first few millimetres and then + // sat there, which made the tint feel like a state the capsule flipped into rather than + // something the gesture was building. This starts gently, does its work through the middle of + // the swipe, and eases into the commit. + val easedDrag = TintRamp.transform(progress) + val armed = progress >= 1f + // Reading the finger position straight into the colour had two problems: it jittered with every + // pointer sample on the way up, and on release `dragUp` resets to 0 in one frame, so the blue + // vanished instantly instead of receding with the capsule. The spring smooths both. It is + // deliberately softer than a tracking spring would be (Medium/1500 chased the finger closely + // enough to reintroduce the jitter) β€” the slight lag is what makes the colour read as fading in. + // Colour is a draw-time property, so this costs a paint, not a re-measure. + val eased by animateFloatAsState( + if (armed) 1f else easedDrag, + spring(dampingRatio = 1f, stiffness = 320f), + label = "shareMorphTint" + ) + // Arming brightens the blue rather than just reaching the end of the ramp, so the commit point + // is visible and not only felt through the haptic. + val tint by animateColorAsState( + if (armed) Color(0xFF4AA8FF) else blue, + tween(240, easing = FastOutSlowInEasing), + label = "shareMorphArmed" + ) + // Note the alpha cap. Lerping all the way to an opaque blue turned the capsule into a flat + // painted pill at the top of the gesture β€” the one moment the material should be most obviously + // alive. Held under 1 it stays a tint *through* the glass: the backdrop still refracts, and the + // colour reads as the surface picking up blue rather than the surface being replaced by it. + // The viewer passes a fully clear [glass] so the idle capsule is pure refraction like Home's + // circles. `Color.Transparent` is transparent *black*, though, and Compose interpolates colour + // and alpha together β€” ramping from it smeared the middle of the swipe through a muddy navy. + // Starting from the tint's own hue at zero alpha keeps the ramp blue the whole way up. + val base = if (glass.alpha == 0f) tint.copy(alpha = 0f) else glass + val surface = lerp(base, tint.copy(alpha = MaxTintAlpha), eased) + + // Fire exactly once on the falseβ†’true edge. Keying the effect on `armed` alone also fired on + // first composition and again on disarm. + var wasArmed by remember { mutableStateOf(false) } + LaunchedEffect(armed) { + if (armed && !wasArmed) haptics.performHapticFeedback(HapticFeedbackType.LongPress) + wasArmed = armed + } + + // Presses down firmly and bounces back on release, like LiquidButton's deformation. + val pressScale by animateFloatAsState( + if (pressed && !shareMode) GlassMotion.PressedScale else 1f, + GlassMotion.press(), + label = "shareMorphPress" + ) + + Box( + modifier + .width(52.dp) + .height(height) + .graphicsLayer { scaleX = pressScale; scaleY = pressScale } + // The viewer's own chrome material, so this sits beside the back and search circles as one + // family rather than as a frostier slab. Capsule, not a 28 dp rounded rectangle, so the + // 52 -> 104 dp morph is an actual circle-to-capsule the whole way up. + .viewerGlass(backdrop, surface, shape = { Capsule }) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + pressed = true + tryAwaitRelease() + pressed = false + }, + onTap = { if (!shareMode) onOpen() } + ) + } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { + shareMode = true; onShareModeChanged(true); dragUp = 0f + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDrag = { change, amount -> change.consume(); dragUp += amount.y }, + onDragEnd = { + if (dragUp < -thresholdPx) onShare() + shareMode = false; onShareModeChanged(false); dragUp = 0f + }, + onDragCancel = { shareMode = false; onShareModeChanged(false); dragUp = 0f } + ) + } + ) { + // Both layers always exist and cross-fade on the shared clock; only the visible one is + // drawn, since a fully transparent graphicsLayer is skipped. + if (morphed < 0.999f) { + Box( + Modifier.fillMaxSize().graphicsLayer { alpha = 1f - morphed }, + contentAlignment = Alignment.Center + ) { + Icon(idleIcon, idleContentDesc, Modifier.size(20.dp), fg) + } + } + if (morphed > 0.001f) { + val ink = lerp(fg, Color.White, eased) + Column( + Modifier + .fillMaxSize() + .graphicsLayer { alpha = morphed } + .padding(vertical = 9.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Rounded.IosShare, + stringResource(R.string.viewer_share_document), + Modifier + .size(20.dp) + // Overshoots to ~1.08 before settling β€” the icon lands with the capsule. + .graphicsLayer { scaleX = pop; scaleY = pop }, + ink + ) + Spacer(Modifier.weight(1f)) + Icon( + Icons.Rounded.KeyboardArrowUp, + null, + Modifier + .size(18.dp) + .graphicsLayer { + // Bob fades out as the gesture arms: once the user is committed, the + // hint stops nagging and the chevron just rides up with the drag. + translationY = (-3f.dp.toPx() * bob) * (1f - eased) - 4f.dp.toPx() * eased + }, + ink.copy(alpha = 0.4f + 0.6f * eased) + ) + } + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/ToolTile.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/ToolTile.kt new file mode 100644 index 0000000..9245421 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/ToolTile.kt @@ -0,0 +1,191 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +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.Spacer +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode + +/** Uniform height for the square-ish grid tiles, sized to fit two lines of Portuguese. */ +val ToolTileHeight = 120.dp + +/** + * Press feedback shared by every tile: the surface eases down under the finger and springs back on + * release, with no grey ripple. Same feel as the recents rows on Home. + */ +@Composable +private fun rememberPressScale(interaction: MutableInteractionSource): Float { + val pressed by interaction.collectIsPressedAsState() + val scale by animateFloatAsState( + if (pressed) 0.96f else 1f, + // Slightly under-damped and a touch stiffer than the rest of the app: release kicks back + // with a visible bounce. Scoped to the tool tiles on purpose β€” everything else stays damped. + spring(dampingRatio = 0.42f, stiffness = Spring.StiffnessMedium), + label = "toolTilePress" + ) + return scale +} + +/** + * A flat tool tile. Deliberately **not** a glass surface: it renders inside a single + * `liquidGlassPanel`, which supplies the refraction for the whole section. Giving each of the 17 + * tools its own blur+lens pass is what made the Tools screen stutter. + */ +@Composable +fun ToolTile( + title: String, + subtitle: String, + accent: Color, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val isLight = !LocalIsDarkMode.current + val interaction = remember { MutableInteractionSource() } + val scale = rememberPressScale(interaction) + + Column( + modifier + .height(ToolTileHeight) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(RoundedCornerShape(20.dp)) + .background(accent.copy(alpha = if (isLight) 0.10f else 0.15f)) + .clickable( + interactionSource = interaction, + indication = null, + role = Role.Button, + onClick = onClick + ) + .padding(14.dp) + ) { + ToolIconTile(icon, accent, title) + Spacer(Modifier.weight(1f)) + BasicText( + title, + style = TextStyle( + color = LiquidGlassColors.text(!isLight), + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.2).sp + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(2.dp)) + BasicText( + subtitle, + style = TextStyle(color = LiquidGlassColors.secondary(!isLight), fontSize = 11.5.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +/** + * Full-width variant used for the single most-reached-for action (Open PDF), which sits above the + * categorised sections rather than inside one. + */ +@Composable +fun ToolTileWide( + title: String, + subtitle: String, + accent: Color, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val isLight = !LocalIsDarkMode.current + val interaction = remember { MutableInteractionSource() } + val scale = rememberPressScale(interaction) + + Row( + modifier + .fillMaxWidth() + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(RoundedCornerShape(22.dp)) + .background(accent.copy(alpha = if (isLight) 0.12f else 0.18f)) + .clickable( + interactionSource = interaction, + indication = null, + role = Role.Button, + onClick = onClick + ) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + ToolIconTile(icon, accent, title) + Column(Modifier.weight(1f)) { + BasicText( + title, + style = TextStyle( + color = LiquidGlassColors.text(!isLight), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.2).sp + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + BasicText( + subtitle, + style = TextStyle(color = LiquidGlassColors.secondary(!isLight), fontSize = 12.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + Icons.AutoMirrored.Rounded.KeyboardArrowRight, + null, + Modifier.size(20.dp), + LiquidGlassColors.secondary(!isLight).copy(0.6f) + ) + } +} + +@Composable +private fun ToolIconTile(icon: ImageVector, accent: Color, contentDescription: String) { + val isLight = !LocalIsDarkMode.current + Box( + Modifier + .size(44.dp) + .clip(RoundedCornerShape(14.dp)) + .background(accent.copy(alpha = if (isLight) 0.18f else 0.28f)), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription, Modifier.size(23.dp), accent) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/ViewerGlass.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/ViewerGlass.kt new file mode 100644 index 0000000..fafa27a --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/ViewerGlass.kt @@ -0,0 +1,145 @@ +package com.chethan616.clearpdf.ui.components + +import androidx.compose.foundation.ScrollState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.shapes.RoundedRectangle + +/** + * The PDF viewer top bar's glass, as a modifier for surfaces that are not buttons. + * + * This is [LiquidIconButton] and [LiquidButton]'s effect stack verbatim β€” `vibrancy`, a 2 dp blur and + * a 12x24 lens, with `highlight`/`shadow` left at `drawBackdrop`'s defaults and no inner shadow β€” + * lifted off their hardcoded circle and capsule so a panel can wear the same material. Those two + * components are read-only, hence the copy rather than a shared helper. + * + * Deliberately lighter than [liquidGlassPanel], which runs an 8 dp blur, a 20x40 depth lens, a + * gravity-angled highlight and an inner shadow. That heavier stack is still right for dialogs: they + * sit over a scrim and need an edge of their own. Chrome sits directly on the document and should read + * as one material with the buttons floating on top of it. + * + * Takes an explicit [color] rather than resolving the theme, so it is not `@Composable` and each + * viewer passes the same `chromeGlass` its buttons already use. + */ +/** + * The corner curve [viewerGlass] paints by default. + * + * Hoisted out of the default argument because anything that *clips* to a viewerGlass surface has to + * use the same curve β€” a clip and a paint that disagree by even a couple of dp reads as a chipped + * edge. See [carouselEdges]. + */ +val ViewerGlassShape: Shape = RoundedRectangle(28f.dp) + +fun Modifier.viewerGlass( + backdrop: Backdrop, + color: Color, + shape: () -> Shape = { ViewerGlassShape }, + // Off for surfaces that must NOT cast a drop shadow β€” e.g. the onboarding page-1 book, whose + // shadow otherwise snapped in the moment the assembled book reached full opacity. + withShadow: Boolean = true +): Modifier = drawBackdrop( + backdrop = backdrop, + shape = shape, + effects = { + vibrancy() + blur(2f.dp.toPx()) + lens(12f.dp.toPx(), 24f.dp.toPx()) + }, + shadow = if (withShadow) ({ com.kyant.backdrop.shadow.Shadow.Default }) else null, + onDrawSurface = { drawRect(color) } +) + +/** + * Soft, scroll-aware edge mask for a horizontally scrolling strip that rides on a [viewerGlass] + * surface. + * + * **The bug this exists to kill.** `Modifier.horizontalScroll` clips to its node's *rectangular* + * bounds, and [viewerGlass] only ever *draws* its rounded shape β€” `drawBackdrop` paints, it does not + * clip. A scrolling row of chips on glass was therefore chopped by a straight vertical line sitting + * inside the capsule's own curve: the one thing a floating glass control must never look like. The + * mask below is a draw-time fade only β€” it deliberately does not hard-clip to the glass shape β€” so + * controls remain visually above the surface while they are being dragged. + * + * Apply it **inside** the glass and **outside** the scroll, with the padding moved to the far end: + * + * ``` + * .viewerGlass(backdrop, glass) + * .carouselEdges(state) + * .horizontalScroll(state) + * .padding(horizontal = 12.dp, vertical = 10.dp) + * ``` + * + * Two things about that order matter. The glass is drawn by the modifier to the *left*, so it is + * outside this layer and the capsule keeps its full opacity at the edges β€” only the chips fade. + * And padding after the scroll is *content* padding that travels with the content, so the first and + * last chips rest with the same inset as the gaps between them yet can still reach the true edge + * mid-scroll. Before the scroll it insets the viewport, which is what put the straight cut 12 dp in + * from the curve. + * + * The fade reads [state] inside the draw lambda, so scrolling invalidates draw only and never + * recomposes. It is absent at rest on the left, retires as you reach the right end, and is short + * ([fade] defaults to 18 dp) β€” a hint that there is more to the side, not a vignette. + */ +fun Modifier.carouselEdges( + state: ScrollState, + shape: Shape = ViewerGlassShape, + fade: Dp = 18.dp, + // Keep the fade mask, but allow a caller with its own overflow-safe layer to opt out of + // clipping the interactive children to the viewport's rounded outline. + clipContent: Boolean = true +): Modifier = this + // One layer does both jobs. `Offscreen` is not decoration: it is what makes the `DstIn` below + // mask this strip rather than punch a hole through everything already on the canvas. + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + clip = clipContent + this.shape = shape + } + .drawWithContent { + drawContent() + val w = size.width + val fadePx = fade.toPx() + if (w <= fadePx * 2f) return@drawWithContent + + val max = state.maxValue + // `maxValue` is Int.MAX_VALUE until the strip has been measured. Treated as "nothing to + // scroll", otherwise a row that fits flashes a trailing fade on its first frame. + val scrollable = max in 1.. + val i = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (i != -1 && c.moveToFirst()) c.getString(i) else null + } + }.getOrNull() ?: uri.lastPathSegment + when (docKindOf(name)) { + DocKind.Excel -> navigate("$ROUTE_SPREADSHEET_BASE?$ARG_PDF_URI=${Uri.encode(uri.toString())}") { launchSingleTop = true } + DocKind.Image -> navigate("$ROUTE_IMAGE_EDITOR_BASE?$ARG_PDF_URI=${Uri.encode(uri.toString())}") { launchSingleTop = true } + else -> navigateToPdfViewer(uri) + } +} + private val MAIN_TAB_ROUTES = setOf(ROUTE_HOME, ROUTE_TOOLS, ROUTE_SETTINGS) private fun routeToTabIdx(route: String?): Int = when (route) { @@ -94,6 +165,23 @@ private fun routeToTabIdx(route: String?): Int = when (route) { else -> 0 } +/** + * The document viewers (PDF / spreadsheet / image). Opening or closing one should read like the + * document lifting off the screen behind it, so during those transitions the underneath screen is + * held perfectly still and opaque β€” NO fade, NO slide β€” while only the viewer scales + fades. + * + * That single rule is what kills the flicker: a scaling viewer is briefly smaller than full-screen, + * and if the screen behind is mid-fade (or already gone) its edges flash the bare window. Keeping + * the backdrop screen static and fully drawn means those edges always reveal a stable image, and + * because every screen shares the same wallpaper the lift looks seamless. + */ +private fun isDocViewerRoute(route: String?): Boolean = + route != null && ( + route.startsWith(ROUTE_VIEWER_BASE) || + route.startsWith(ROUTE_SPREADSHEET_BASE) || + route.startsWith(ROUTE_IMAGE_EDITOR_BASE) + ) + @Composable fun DocsNavGraph( navController: NavHostController, @@ -104,15 +192,23 @@ fun DocsNavGraph( onDarkModeChanged: (Boolean) -> Unit, themeMode: Int, onThemeModeChanged: (Int) -> Unit, + showWallpaper: Boolean = true, + onShowWallpaperChanged: (Boolean) -> Unit = {}, + hasCustomWallpaper: Boolean = false, + onCustomWallpaperChanged: (String?) -> Unit = {}, selectedLocale: String, onLocaleChanged: (String) -> Unit, - incomingPdfUri: Uri? = null + incomingPdfUri: Uri? = null, + startDestination: String = ROUTE_HOME, + // Onboarding's language picker takes this instead of [onLocaleChanged]: it must NOT restart the + // Activity, or the flow would relaunch at page one under the user. The restart, if the choice + // actually changed anything, is deferred to [onOnboardingFinished]. + onOnboardingLocaleSelected: (String) -> Unit = {}, + onOnboardingFinished: () -> Unit = {} ) { - val context = LocalContext.current - val startDest = if (OnboardingManager.hasCompletedOnboarding(context)) ROUTE_HOME else ROUTE_ONBOARDING NavHost( navController = navController, - startDestination = startDest, + startDestination = startDestination, enterTransition = { val isMainTabSwitch = initialState.destination.route in MAIN_TAB_ROUTES && targetState.destination.route in MAIN_TAB_ROUTES if (isMainTabSwitch) { @@ -127,15 +223,19 @@ fun DocsNavGraph( ) + androidx.compose.animation.slideInHorizontally( animationSpec = androidx.compose.animation.core.spring( dampingRatio = androidx.compose.animation.core.Spring.DampingRatioNoBouncy, - stiffness = androidx.compose.animation.core.Spring.StiffnessMedium + stiffness = androidx.compose.animation.core.Spring.StiffnessMediumLow ), - initialOffsetX = { (it * 0.06f).toInt() } + initialOffsetX = { (it * 0.22f).toInt() } ) } }, exitTransition = { val isMainTabSwitch = initialState.destination.route in MAIN_TAB_ROUTES && targetState.destination.route in MAIN_TAB_ROUTES - if (isMainTabSwitch) { + if (isDocViewerRoute(targetState.destination.route) && !isDocViewerRoute(initialState.destination.route)) { + // Opening a document viewer: hold this screen still + opaque behind the lifting + // viewer so its scaling edges never flash the bare window. See [isDocViewerRoute]. + ExitTransition.None + } else if (isMainTabSwitch) { val dist = kotlin.math.abs(routeToTabIdx(targetState.destination.route) - routeToTabIdx(initialState.destination.route)) val fadeDuration = if (dist >= 2) 520 else 350 androidx.compose.animation.fadeOut( @@ -147,15 +247,20 @@ fun DocsNavGraph( ) + androidx.compose.animation.slideOutHorizontally( animationSpec = androidx.compose.animation.core.spring( dampingRatio = androidx.compose.animation.core.Spring.DampingRatioNoBouncy, - stiffness = androidx.compose.animation.core.Spring.StiffnessMedium + stiffness = androidx.compose.animation.core.Spring.StiffnessMediumLow ), - targetOffsetX = { (-it * 0.06f).toInt() } + targetOffsetX = { (-it * 0.10f).toInt() } ) } }, popEnterTransition = { val isMainTabSwitch = initialState.destination.route in MAIN_TAB_ROUTES && targetState.destination.route in MAIN_TAB_ROUTES - if (isMainTabSwitch) { + if (isDocViewerRoute(initialState.destination.route) && !isDocViewerRoute(targetState.destination.route)) { + // Returning from a document viewer: this screen is already there β€” don't re-animate + // it. Just let the viewer scale/fade away on top of it. Re-fading it in was the + // "flash + black edge" flicker on back. See [isDocViewerRoute]. + EnterTransition.None + } else if (isMainTabSwitch) { val dist = kotlin.math.abs(routeToTabIdx(targetState.destination.route) - routeToTabIdx(initialState.destination.route)) val fadeDuration = if (dist >= 2) 520 else 350 androidx.compose.animation.fadeIn( @@ -167,9 +272,9 @@ fun DocsNavGraph( ) + androidx.compose.animation.slideInHorizontally( animationSpec = androidx.compose.animation.core.spring( dampingRatio = androidx.compose.animation.core.Spring.DampingRatioNoBouncy, - stiffness = androidx.compose.animation.core.Spring.StiffnessMedium + stiffness = androidx.compose.animation.core.Spring.StiffnessMediumLow ), - initialOffsetX = { (-it * 0.06f).toInt() } + initialOffsetX = { (-it * 0.10f).toInt() } ) } }, @@ -187,25 +292,45 @@ fun DocsNavGraph( ) + androidx.compose.animation.slideOutHorizontally( animationSpec = androidx.compose.animation.core.spring( dampingRatio = androidx.compose.animation.core.Spring.DampingRatioNoBouncy, - stiffness = androidx.compose.animation.core.Spring.StiffnessMedium + stiffness = androidx.compose.animation.core.Spring.StiffnessMediumLow ), - targetOffsetX = { (it * 0.06f).toInt() } + targetOffsetX = { (it * 0.22f).toInt() } ) } } ) { - // ── Onboarding (first launch only) ── + // ── First run ── composable(ROUTE_ONBOARDING) { OnboardingScreen( backdrop = backdrop, selectedLocale = selectedLocale, - onLanguageChanged = onLocaleChanged, - onComplete = { - navController.navigate(ROUTE_HOME) { - popUpTo(ROUTE_ONBOARDING) { inclusive = true } - launchSingleTop = true + onLocaleSelected = onOnboardingLocaleSelected, + // The real hoisted appearance state, not a copy: the tour's own glass re-tints as + // the user taps, and whatever they pick is already persisted by the time they leave. + themeMode = themeMode, + onThemeModeChanged = onThemeModeChanged, + showWallpaper = showWallpaper, + onShowWallpaperChanged = onShowWallpaperChanged, + hasCustomWallpaper = hasCustomWallpaper, + onCustomWallpaperChanged = onCustomWallpaperChanged, + onFinish = { + // The host records completion and, only if the locale actually changed, starts + // its fade + recreate. Either way we leave this route. + onOnboardingFinished() + // Two arrivals to unwind. On a first run onboarding IS the start destination and + // there is nothing behind it, so we navigate to Home and drop this route. On a + // replay from Settings the stack is [home, settings, onboarding] β€” navigating + // would push a second Home and strand Settings under it, so pop instead and land + // back on the Settings row the replay was launched from. + if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } else { + navController.navigate(ROUTE_HOME) { + popUpTo(ROUTE_ONBOARDING) { inclusive = true } + launchSingleTop = true + } } } ) @@ -214,16 +339,30 @@ fun DocsNavGraph( // ── Main tabs ── composable(ROUTE_HOME) { + val homeContext = LocalContext.current + // Open picker that routes by document kind (spreadsheets β†’ grid viewer, else PDF viewer). + val openDocLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + runCatching { + homeContext.contentResolver.takePersistableUriPermission(uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + navController.navigateToDocument(homeContext, uri) + } + } HomeScreen( backdrop = backdrop, onNavigateToOpenPdf = { - navController.navigateToPdfViewer() + runCatching { openDocLauncher.launch(OPEN_DOC_MIMES) } + .onFailure { navController.navigateToPdfViewer() } }, onNavigateToScan = { navController.navigate(ROUTE_SCAN) { launchSingleTop = true } }, - onRecentFileSelected = { uri -> - navController.navigateToPdfViewer(uri) + onRecentFileSelected = { uri, name -> + // Route by document kind: spreadsheets open in the interactive grid viewer, + // everything else in the PDF viewer. The name comes from the recents entry + // rather than a fresh provider query β€” see [navigateToDocument]. + navController.navigateToDocument(homeContext, uri, name) } ) } @@ -240,17 +379,36 @@ fun DocsNavGraph( onNavigateToExtractText = { navController.navigate(ROUTE_EXTRACT_TEXT) { launchSingleTop = true } }, onNavigateToImagesToPdf = { navController.navigate(ROUTE_IMAGES_TO_PDF) { launchSingleTop = true } }, onNavigateToDecryptPdf = { navController.navigate(ROUTE_DECRYPT_PDF) { launchSingleTop = true } }, - onNavigateToEncryptPdf = { navController.navigate(ROUTE_ENCRYPT_PDF) { launchSingleTop = true } } + onNavigateToEncryptPdf = { navController.navigate(ROUTE_ENCRYPT_PDF) { launchSingleTop = true } }, + onNavigateToPdfToImages = { navController.navigate(ROUTE_PDF_TO_IMAGES) { launchSingleTop = true } }, + onNavigateToWatermark = { navController.navigate(ROUTE_WATERMARK) { launchSingleTop = true } }, + onNavigateToExtractPages = { navController.navigate(ROUTE_EXTRACT_PAGES) { launchSingleTop = true } }, + onNavigateToPageNumbers = { navController.navigate(ROUTE_PAGE_NUMBERS) { launchSingleTop = true } }, + onNavigateToFlatten = { navController.navigate(ROUTE_FLATTEN) { launchSingleTop = true } }, + onNavigateToImageTools = { navController.navigate(ROUTE_IMAGE_TOOLS) { launchSingleTop = true } }, + onNavigateToHtmlToPdf = { navController.navigate(ROUTE_HTML_TO_PDF) { launchSingleTop = true } }, + onNavigateToFillForm = { navController.navigate(ROUTE_FILL_FORM) { launchSingleTop = true } } ) } composable(ROUTE_SETTINGS) { + val settingsContext = LocalContext.current SettingsScreen( + onReplayOnboarding = { + // Clear the flag too, not just navigate: otherwise quitting the replay early + // leaves the tour marked "seen" while the user never finished it. + com.chethan616.clearpdf.data.repository.OnboardingManager.resetOnboarding(settingsContext) + navController.navigate(ROUTE_ONBOARDING) { launchSingleTop = true } + }, backdrop = backdrop, isDarkMode = isDarkMode, onDarkModeChanged = onDarkModeChanged, themeMode = themeMode, onThemeModeChanged = onThemeModeChanged, + showWallpaper = showWallpaper, + onShowWallpaperChanged = onShowWallpaperChanged, + hasCustomWallpaper = hasCustomWallpaper, + onCustomWallpaperChanged = onCustomWallpaperChanged, selectedLocale = selectedLocale, onLocaleChanged = onLocaleChanged ) @@ -291,7 +449,29 @@ fun DocsNavGraph( nullable = true defaultValue = null } - ) + ), + // "Document lifts open": one coordinated fade + soft-spring zoom-up from slightly + // smaller. The screen behind is held static+opaque (see [isDocViewerRoute]), so the + // lift reads against a stable backdrop with no edge flash. The earlier vertical slide is + // gone β€” a single centered scale settling on a spring is cleaner and flicker-free. + enterTransition = { + androidx.compose.animation.fadeIn(tween(200, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + + androidx.compose.animation.scaleIn( + initialScale = 0.92f, + animationSpec = androidx.compose.animation.core.spring( + dampingRatio = 0.85f, + stiffness = androidx.compose.animation.core.Spring.StiffnessMediumLow + ) + ) + }, + exitTransition = { androidx.compose.animation.fadeOut(tween(160)) }, + popEnterTransition = { androidx.compose.animation.fadeIn(tween(200)) }, + // Closing: the viewer settles back down + fades, revealing the (already-present) screen + // underneath. Symmetric with the open so back feels like the inverse of the lift. + popExitTransition = { + androidx.compose.animation.fadeOut(tween(210, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + + androidx.compose.animation.scaleOut(targetScale = 0.94f, animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + } ) { backStackEntry -> val context = LocalContext.current val vm: PdfViewerViewModel = viewModel( @@ -311,7 +491,84 @@ fun DocsNavGraph( vm.openPdf(context, targetUri) } } - PdfViewerScreen(backdrop = backdrop, viewModel = vm, onBack = { navController.popBackStack() }) + // We were handed a document to open (recents / external / a tool's output), so the viewer + // should show a loading curtain rather than flash its "Open a PDF" picker before the pages + // arrive. See PdfViewerScreen's [pendingLoad]. + val hasPendingDoc = routeUri != null || incomingPdfUri != null + PdfViewerScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + pendingLoad = hasPendingDoc + ) + } + + composable( + route = ROUTE_SPREADSHEET, + arguments = listOf(navArgument(ARG_PDF_URI) { type = NavType.StringType; nullable = true; defaultValue = null }), + // Same "document lifts open" as the PDF viewer (see that route) for a uniform feel. + enterTransition = { + androidx.compose.animation.fadeIn(tween(200, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + + androidx.compose.animation.scaleIn( + initialScale = 0.92f, + animationSpec = androidx.compose.animation.core.spring( + dampingRatio = 0.85f, + stiffness = androidx.compose.animation.core.Spring.StiffnessMediumLow + ) + ) + }, + popExitTransition = { + androidx.compose.animation.fadeOut(tween(210, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + + androidx.compose.animation.scaleOut(targetScale = 0.94f, animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + } + ) { backStackEntry -> + val context = LocalContext.current + val vm: SpreadsheetViewModel = viewModel() + val routeUri = backStackEntry.arguments?.getString(ARG_PDF_URI)?.let { parseViewerUriArg(it) } + LaunchedEffect(routeUri, incomingPdfUri) { + val target = routeUri ?: incomingPdfUri + if (target != null) vm.load(context, target) + } + SpreadsheetViewerScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + onOpenPdf = { pdfUri -> navController.navigateToPdfViewer(pdfUri) } + ) + } + + composable( + route = ROUTE_IMAGE_EDITOR, + arguments = listOf(navArgument(ARG_PDF_URI) { type = NavType.StringType; nullable = true; defaultValue = null }), + // Same "document lifts open" as the PDF viewer (see that route) for a uniform feel. + enterTransition = { + androidx.compose.animation.fadeIn(tween(200, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + + androidx.compose.animation.scaleIn( + initialScale = 0.92f, + animationSpec = androidx.compose.animation.core.spring( + dampingRatio = 0.85f, + stiffness = androidx.compose.animation.core.Spring.StiffnessMediumLow + ) + ) + }, + popExitTransition = { + androidx.compose.animation.fadeOut(tween(210, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + + androidx.compose.animation.scaleOut(targetScale = 0.94f, animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing)) + } + ) { backStackEntry -> + val context = LocalContext.current + val vm: ImageEditorViewModel = viewModel() + val routeUri = backStackEntry.arguments?.getString(ARG_PDF_URI)?.let { parseViewerUriArg(it) } + LaunchedEffect(routeUri, incomingPdfUri) { + val target = routeUri ?: incomingPdfUri + if (target != null) vm.load(context, target) + } + ImageEditorScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + onOpenPdf = { pdfUri -> navController.navigateToPdfViewer(pdfUri) } + ) } composable(ROUTE_MERGE) { @@ -424,5 +681,83 @@ fun DocsNavGraph( onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } ) } + + composable(ROUTE_PDF_TO_IMAGES) { + val vm: PdfToImagesViewModel = viewModel() + PdfToImagesScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() } + ) + } + + composable(ROUTE_WATERMARK) { + val vm: WatermarkPdfViewModel = viewModel() + WatermarkPdfScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } + ) + } + + composable(ROUTE_EXTRACT_PAGES) { + val vm: ExtractPagesViewModel = viewModel() + ExtractPagesScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } + ) + } + + composable(ROUTE_PAGE_NUMBERS) { + val vm: PageNumbersViewModel = viewModel() + PageNumbersScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } + ) + } + + composable(ROUTE_FLATTEN) { + val vm: FlattenPdfViewModel = viewModel() + FlattenPdfScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } + ) + } + + composable(ROUTE_IMAGE_TOOLS) { + val vm: ImageToolsViewModel = viewModel() + ImageToolsScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() } + ) + } + + composable(ROUTE_HTML_TO_PDF) { + val vm: HtmlToPdfViewModel = viewModel() + HtmlToPdfScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } + ) + } + + composable(ROUTE_FILL_FORM) { + val vm: FillFormViewModel = viewModel() + FillFormScreen( + backdrop = backdrop, + viewModel = vm, + onBack = { navController.popBackStack() }, + onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } + ) + } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/CompressPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/CompressPdfScreen.kt index 056d2aa..c1c8fad 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/CompressPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/CompressPdfScreen.kt @@ -47,9 +47,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.ui.components.GlassChip import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar import com.chethan616.clearpdf.ui.components.LiquidSlider +import com.chethan616.clearpdf.ui.components.ToolScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor @@ -86,66 +87,11 @@ fun CompressPdfScreen( if (uri != null) viewModel.onSelectFile(context, uri) } - var isVisible by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { isVisible = true } - val density = androidx.compose.ui.platform.LocalDensity.current.density - - val topBarAlpha by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "compressTopBarAlpha" - ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 16f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "compressTopBarOffsetY" - ) - - val contentAlpha by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 600, delayMillis = 100, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "compressContentAlpha" - ) - val contentOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 24f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 600, delayMillis = 100, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "compressContentOffsetY" - ) - - Column( - Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + ToolScaffold( + title = stringResource(R.string.tool_compress), + backdrop = backdrop, + onBack = onBack ) { - // Floating Liquid Glass Top Bar - Row( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(title = stringResource(R.string.tool_compress), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f), titleFontSize = 18.sp) - } - - // Scrollable Body Content - Column( - Modifier - .fillMaxWidth() - .weight(1f) - .graphicsLayer { - alpha = contentAlpha - translationY = contentOffsetY * density - } - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { Column( Modifier .fillMaxWidth() @@ -201,17 +147,7 @@ fun CompressPdfScreen( Icon(Icons.Rounded.HighQuality, null, Modifier.size(22.dp), accent) BasicText(stringResource(R.string.settings_compression_quality), style = TextStyle(text, 16.sp, fontWeight = FontWeight.SemiBold)) } - Box( - Modifier - .clip(RoundedCornerShape(6.dp)) - .background(accent.copy(0.12f)) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) { - BasicText( - "${(state.qualitySlider * 100).toInt()}%", - style = TextStyle(accent, 13.sp, fontWeight = FontWeight.Bold) - ) - } + GlassChip("${(state.qualitySlider * 100).toInt()}%", accent) } LiquidSlider( @@ -242,11 +178,11 @@ fun CompressPdfScreen( // Compress button LiquidButton( - onClick = { viewModel.onCompress(context) }, + onClick = { if (!state.isCompressing) viewModel.onCompress(context) }, backdrop = backdrop, tint = accent, - isInteractive = !state.isCompressing + modifier = Modifier.fillMaxWidth() ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { if (state.isCompressing) { CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) } else { @@ -254,7 +190,8 @@ fun CompressPdfScreen( } BasicText( if (state.isCompressing) "Compressing..." else "Compress Now", - style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium) + style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), + maxLines = 1 ) } } @@ -281,6 +218,5 @@ fun CompressPdfScreen( } Spacer(Modifier.height(40.dp)) - } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/CreatePdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/CreatePdfScreen.kt index b52f0ee..acd0368 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/CreatePdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/CreatePdfScreen.kt @@ -62,7 +62,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.rememberAsyncImagePainter import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.LiquidSaveDialog import com.chethan616.clearpdf.ui.components.liquidGlassPanel import androidx.compose.runtime.mutableStateOf @@ -161,12 +163,6 @@ fun CreatePdfScreen( animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), label = "createTopBarAlpha" ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 16f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "createTopBarOffsetY" - ) - val contentAlpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (isVisible) 1f else 0f, animationSpec = androidx.compose.animation.core.tween(durationMillis = 600, delayMillis = 100, easing = androidx.compose.animation.core.FastOutSlowInEasing), @@ -178,32 +174,24 @@ fun CreatePdfScreen( label = "createContentOffsetY" ) - Column( - Modifier - .fillMaxSize() - .imePadding() - .statusBarsPadding() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Row( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(title = stringResource(R.string.tool_create), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) + GlassScreenScaffold( + backdrop = backdrop, + header = { headerBackdrop -> + // Fade only β€” the header is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = stringResource(R.string.tool_create), + backdrop = headerBackdrop, + onBack = onBack, + modifier = Modifier.graphicsLayer { alpha = topBarAlpha } + ) } - + ) { contentPadding -> Column( Modifier - .fillMaxWidth() + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(contentPadding) .graphicsLayer { alpha = contentAlpha translationY = contentOffsetY * density diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt index 2f34c50..c065ca4 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -47,7 +48,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.R import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor @@ -111,20 +114,24 @@ fun DecryptPdfScreen( password = "" } + GlassScreenScaffold( + backdrop = backdrop, + header = { headerBackdrop -> + GlassScreenHeaderRow( + title = stringResource(R.string.decrypt_pdf_title), + backdrop = headerBackdrop, + onBack = onBack + ) + } + ) { contentPadding -> Column( Modifier .fillMaxSize() - .statusBarsPadding() - .padding(16.dp) - .verticalScroll(rememberScrollState()), + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(stringResource(R.string.decrypt_pdf_title), backdrop, uiSensor, Modifier.weight(1f)) - } Column( Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -207,4 +214,5 @@ fun DecryptPdfScreen( Modifier.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 68.dp) ) } + } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt index 930ec5b..a663bf7 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -48,7 +49,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.R import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor @@ -124,20 +127,24 @@ fun EncryptPdfScreen( confirmPassword = "" } + GlassScreenScaffold( + backdrop = backdrop, + header = { headerBackdrop -> + GlassScreenHeaderRow( + title = stringResource(R.string.encrypt_pdf_title), + backdrop = headerBackdrop, + onBack = onBack + ) + } + ) { contentPadding -> Column( Modifier .fillMaxSize() - .statusBarsPadding() - .padding(16.dp) - .verticalScroll(rememberScrollState()), + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(stringResource(R.string.encrypt_pdf_title), backdrop, uiSensor, Modifier.weight(1f)) - } Column( Modifier @@ -238,6 +245,7 @@ fun EncryptPdfScreen( Modifier.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 68.dp) ) } + } } @Composable diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractPagesScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractPagesScreen.kt new file mode 100644 index 0000000..60c6c33 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractPagesScreen.kt @@ -0,0 +1,157 @@ +package com.chethan616.clearpdf.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCut +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.text.KeyboardOptions +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.GlassSectionHeader +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidGlassErrorCard +import com.chethan616.clearpdf.ui.components.ToolScaffold +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.ExtractPagesViewModel +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay + +private val ExtractAccent = Color(0xFF00897B) + +@Composable +fun ExtractPagesScreen( + backdrop: LayerBackdrop, + viewModel: ExtractPagesViewModel, + onBack: () -> Unit, + onViewOutput: (Uri) -> Unit +) { + val state by viewModel.uiState.collectAsState() + val isDark = LocalIsDarkMode.current + val isLight = !isDark + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) + val uiSensor = rememberUISensor() + val context = LocalContext.current + + LaunchedEffect(state.resultMessage, state.errorMessage) { + if (state.resultMessage != null || state.errorMessage != null) { delay(3500); viewModel.clearFeedback() } + } + + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> if (uri != null) viewModel.onSelectFile(context, uri) } + + ToolScaffold( + title = stringResource(R.string.tool_extract_pages), + backdrop = backdrop, + onBack = onBack + ) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon(Icons.Rounded.ContentCut, null, Modifier.size(56.dp), ExtractAccent) + BasicText(stringResource(R.string.tool_extract_pages), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.tool_extract_pages_sub), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + LiquidButton(onClick = { filePicker.launch(arrayOf("application/pdf")) }, backdrop = backdrop, tint = ExtractAccent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + } + + if (state.sourceUri != null) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + GlassSectionHeader( + title = stringResource(R.string.extract_pages_label), + icon = Icons.Rounded.ContentCut, + iconTint = ExtractAccent, + titleColor = text, + trailing = { BasicText(stringResource(R.string.extract_pages_total, state.pageCount), style = TextStyle(sub, 13.sp)) } + ) + BasicText(state.sourceName, style = TextStyle(sub, 13.sp), maxLines = 1) + BasicTextField( + value = state.rangeText, + onValueChange = viewModel::onRangeChange, + singleLine = true, + textStyle = TextStyle(text, 15.sp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f)) + .padding(horizontal = 12.dp, vertical = 12.dp), + decorationBox = { inner -> + if (state.rangeText.isEmpty()) BasicText(stringResource(R.string.extract_pages_hint), style = TextStyle(sub, 14.sp)) + inner() + } + ) + } + + LiquidButton( + onClick = { if (!state.isProcessing) viewModel.apply(context) }, + backdrop = backdrop, tint = ExtractAccent, + modifier = Modifier.fillMaxWidth() + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + if (state.isProcessing) CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) + else Icon(Icons.Rounded.ContentCut, null, Modifier.size(18.dp), Color.White) + BasicText( + stringResource(if (state.isProcessing) R.string.extract_pages_working else R.string.extract_pages_action), + style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1 + ) + } + } + } + + if (state.errorMessage != null) { + LiquidGlassErrorCard(message = state.errorMessage!!, backdrop = backdrop, uiSensor = uiSensor, onDismiss = { viewModel.clearFeedback() }) + } + + state.lastOutputUri?.let { outUri -> + LiquidButton(onClick = { onViewOutput(outUri) }, backdrop = backdrop, tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth()) { + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold), modifier = Modifier.padding(vertical = 8.dp)) + } + } + + Spacer(Modifier.height(40.dp)) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractTextScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractTextScreen.kt index ab14c86..d3cfa87 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractTextScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ExtractTextScreen.kt @@ -42,7 +42,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor @@ -83,11 +85,6 @@ fun ExtractTextScreen( animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), label = "extractTopBarAlpha" ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 16f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "extractTopBarOffsetY" - ) val contentAlpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (isVisible) 1f else 0f, @@ -100,28 +97,25 @@ fun ExtractTextScreen( label = "extractContentOffsetY" ) - Column( - Modifier.fillMaxSize().statusBarsPadding().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(stringResource(R.string.tool_extract), backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) + GlassScreenScaffold( + backdrop = backdrop, + contentBottomPadding = 16.dp, + header = { headerBackdrop -> + // Fade only β€” the header is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = stringResource(R.string.tool_extract), + backdrop = headerBackdrop, + onBack = onBack, + modifier = Modifier.graphicsLayer { alpha = topBarAlpha } + ) } - + ) { contentPadding -> + // The body fills rather than scrolls (the extracted text has its own scroller), so the + // header clearance is real padding here, not a scrolled inset. Column( Modifier - .fillMaxWidth() - .weight(1f) + .fillMaxSize() + .padding(contentPadding) .graphicsLayer { alpha = contentAlpha translationY = contentOffsetY * density diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/FillFormScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/FillFormScreen.kt new file mode 100644 index 0000000..3ce540c --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/FillFormScreen.kt @@ -0,0 +1,179 @@ +package com.chethan616.clearpdf.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.EditNote +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +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 androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.GlassSectionHeader +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidGlassErrorCard +import com.chethan616.clearpdf.ui.components.LiquidToggle +import com.chethan616.clearpdf.ui.components.ToolScaffold +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.FillFormViewModel +import com.kyant.pdfcore.form.PdfFormService +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay + +private val FormAccent = Color(0xFF00695C) + +@Composable +fun FillFormScreen( + backdrop: LayerBackdrop, + viewModel: FillFormViewModel, + onBack: () -> Unit, + onViewOutput: (Uri) -> Unit +) { + val state by viewModel.uiState.collectAsState() + val isDark = LocalIsDarkMode.current + val isLight = !isDark + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) + val uiSensor = rememberUISensor() + val context = LocalContext.current + + LaunchedEffect(state.resultMessage, state.errorMessage) { + if (state.resultMessage != null || state.errorMessage != null) { delay(3500); viewModel.clearFeedback() } + } + + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> if (uri != null) viewModel.onSelectFile(context, uri) } + + ToolScaffold( + title = stringResource(R.string.tool_fill_form), + backdrop = backdrop, + onBack = onBack + ) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon(Icons.Rounded.EditNote, null, Modifier.size(56.dp), FormAccent) + BasicText(stringResource(R.string.tool_fill_form), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.tool_fill_form_sub), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + LiquidButton(onClick = { filePicker.launch(arrayOf("application/pdf")) }, backdrop = backdrop, tint = FormAccent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + } + + if (state.fields.isNotEmpty()) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + GlassSectionHeader( + title = stringResource(R.string.fill_form_fields), + icon = Icons.Rounded.EditNote, iconTint = FormAccent, titleColor = text, + trailing = { BasicText("${state.fields.size}", style = TextStyle(sub, 13.sp)) } + ) + state.fields.forEach { field -> + when (field.type) { + PdfFormService.FieldType.CHECKBOX -> { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + BasicText(field.name, style = TextStyle(text, 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + LiquidToggle( + selected = { field.value == "true" }, + onSelect = { viewModel.onFieldChange(field.name, if (it) "true" else "false") }, + backdrop = backdrop + ) + } + } + else -> { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + BasicText(field.name, style = TextStyle(sub, 12.sp), maxLines = 1, overflow = TextOverflow.Ellipsis) + BasicTextField( + value = field.value, + onValueChange = { viewModel.onFieldChange(field.name, it) }, + singleLine = true, + textStyle = TextStyle(text, 15.sp), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f)) + .padding(horizontal = 12.dp, vertical = 10.dp) + ) + if (field.options.isNotEmpty()) { + BasicText(field.options.joinToString(" Β· "), style = TextStyle(sub.copy(0.8f), 11.sp), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Column(Modifier.weight(1f)) { + BasicText(stringResource(R.string.fill_form_flatten), style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.fill_form_flatten_desc), style = TextStyle(sub, 12.sp)) + } + LiquidToggle(selected = { state.flatten }, onSelect = viewModel::onFlattenChange, backdrop = backdrop) + } + } + + LiquidButton( + onClick = { if (!state.isProcessing) viewModel.save(context) }, + backdrop = backdrop, tint = FormAccent, modifier = Modifier.fillMaxWidth() + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + if (state.isProcessing) CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) + else Icon(Icons.Rounded.EditNote, null, Modifier.size(18.dp), Color.White) + BasicText( + stringResource(if (state.isProcessing) R.string.fill_form_working else R.string.fill_form_action), + style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1 + ) + } + } + } + + if (state.errorMessage != null) { + LiquidGlassErrorCard(message = state.errorMessage!!, backdrop = backdrop, uiSensor = uiSensor, onDismiss = { viewModel.clearFeedback() }) + } + + state.lastOutputUri?.let { outUri -> + LiquidButton(onClick = { onViewOutput(outUri) }, backdrop = backdrop, tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth()) { + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold), modifier = Modifier.padding(vertical = 8.dp)) + } + } + + Spacer(Modifier.height(40.dp)) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/FlattenPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/FlattenPdfScreen.kt new file mode 100644 index 0000000..5a4fd40 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/FlattenPdfScreen.kt @@ -0,0 +1,127 @@ +package com.chethan616.clearpdf.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Layers +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidGlassErrorCard +import com.chethan616.clearpdf.ui.components.ToolScaffold +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.FlattenPdfViewModel +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay + +private val FlattenAccent = Color(0xFF6D4C41) + +@Composable +fun FlattenPdfScreen( + backdrop: LayerBackdrop, + viewModel: FlattenPdfViewModel, + onBack: () -> Unit, + onViewOutput: (Uri) -> Unit +) { + val state by viewModel.uiState.collectAsState() + val isDark = LocalIsDarkMode.current + val isLight = !isDark + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) + val uiSensor = rememberUISensor() + val context = LocalContext.current + + LaunchedEffect(state.resultMessage, state.errorMessage) { + if (state.resultMessage != null || state.errorMessage != null) { delay(3500); viewModel.clearFeedback() } + } + + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> if (uri != null) viewModel.onSelectFile(context, uri) } + + ToolScaffold( + title = stringResource(R.string.tool_flatten), + backdrop = backdrop, + onBack = onBack + ) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon(Icons.Rounded.Layers, null, Modifier.size(56.dp), FlattenAccent) + BasicText(stringResource(R.string.tool_flatten), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.flatten_desc), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + LiquidButton(onClick = { filePicker.launch(arrayOf("application/pdf")) }, backdrop = backdrop, tint = FlattenAccent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + } + + if (state.sourceUri != null) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + BasicText(state.sourceName, style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1) + BasicText(stringResource(R.string.flatten_hint), style = TextStyle(sub, 13.sp)) + } + + LiquidButton( + onClick = { if (!state.isProcessing) viewModel.apply(context) }, + backdrop = backdrop, tint = FlattenAccent, modifier = Modifier.fillMaxWidth() + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + if (state.isProcessing) CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) + else Icon(Icons.Rounded.Layers, null, Modifier.size(18.dp), Color.White) + BasicText( + stringResource(if (state.isProcessing) R.string.flatten_working else R.string.flatten_action), + style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1 + ) + } + } + } + + if (state.errorMessage != null) { + LiquidGlassErrorCard(message = state.errorMessage!!, backdrop = backdrop, uiSensor = uiSensor, onDismiss = { viewModel.clearFeedback() }) + } + + state.lastOutputUri?.let { outUri -> + LiquidButton(onClick = { onViewOutput(outUri) }, backdrop = backdrop, tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth()) { + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold), modifier = Modifier.padding(vertical = 8.dp)) + } + } + + Spacer(Modifier.height(40.dp)) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/HomeScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/HomeScreen.kt index d875586..c94f720 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/HomeScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/HomeScreen.kt @@ -2,97 +2,122 @@ package com.chethan616.clearpdf.ui.screen import android.content.Intent import android.net.Uri +import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material.icons.rounded.Apps import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.FileOpen +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.GridOn +import androidx.compose.material.icons.rounded.Image import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.OpenInNew +import androidx.compose.material.icons.rounded.IosShare import androidx.compose.material.icons.rounded.PictureAsPdf -import androidx.compose.material.icons.rounded.RemoveCircleOutline +import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material.icons.rounded.Scanner -import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.Slideshow import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import com.chethan616.clearpdf.R -import androidx.compose.ui.res.stringResource import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily 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.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.lerp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.chethan616.clearpdf.R import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.ui.components.CloseCrossIcon +import com.chethan616.clearpdf.ui.components.GlassCapsuleMenu +import com.chethan616.clearpdf.ui.components.GlassMenuAction +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold +import com.chethan616.clearpdf.ui.components.GlassSearchHeader import com.chethan616.clearpdf.ui.components.LiquidButton import com.chethan616.clearpdf.ui.components.LiquidIconButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LiquidGlassColors import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.utils.DocKind +import com.chethan616.clearpdf.utils.docKindOf import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import com.chethan616.clearpdf.ui.components.CloseCrossIcon - -import androidx.compose.ui.graphics.graphicsLayer - private val SourGummyFontFamily = FontFamily( Font(R.font.sour_gummy_regular, FontWeight.Normal), Font(R.font.sour_gummy_bold, FontWeight.Bold) @@ -103,7 +128,11 @@ fun HomeScreen( backdrop: LayerBackdrop, onNavigateToOpenPdf: () -> Unit, onNavigateToScan: () -> Unit, - onRecentFileSelected: (Uri) -> Unit + // The stored display name travels with the uri. Re-querying DISPLAY_NAME at tap time is a + // guess that fails exactly when it matters: a lapsed permission or a provider that answers with + // its own internal name sends a .docx down the plain-PDF route. Recents already knows what the + // file is called, so routing should ask recents. + onRecentFileSelected: (Uri, String) -> Unit ) { val homeRecentLimit = 5 val isDarkMode = LocalIsDarkMode.current @@ -114,10 +143,22 @@ fun HomeScreen( val redAccent = LiquidGlassColors.Red val uiSensor = rememberUISensor() val context = LocalContext.current + val homeScope = rememberCoroutineScope() var recents by remember { mutableStateOf(RecentFilesManager.getRecents(context)) } var showAllRecents by remember { mutableStateOf(false) } + var recentQuery by remember { mutableStateOf("") } + var searchActive by remember { mutableStateOf(false) } var selectedRecent by remember { mutableStateOf(null) } + // The row currently playing its exit animation because "Remove" was tapped in the long-press + // menu. Swipe-to-delete drives its own exit from inside the row; this lets the menu path run the + // exact same fade + container spring instead of snapping the row out of existence. + var pendingDeleteUri by remember { mutableStateOf(null) } + // Root-space vertical center of the long-pressed row, so the popup can rise + // from the item instead of floating dead-center. + var selectedRecentAnchorY by remember { mutableStateOf(0f) } // root-space TOP of the pressed row + var selectedRecentRowHeight by remember { mutableStateOf(0f) } + var recentPopupHeightPx by remember { mutableStateOf(0) } var infoRecent by remember { mutableStateOf(null) } val lifecycleOwner = LocalLifecycleOwner.current @@ -129,23 +170,17 @@ fun HomeScreen( transitionSpec = { spring(dampingRatio = 0.82f, stiffness = 220f) }, label = "morphProgress" ) { if (it) 1f else 0f } - // One transition coordinates the buttons with the sheet. This removes four - // independent state machines and keeps the glass surface on a single frame clock. - val b1Scale by popupTransition.animateFloat( - transitionSpec = { tween(240, if (targetState) 110 else 0) }, - label = "popupOpenScale" - ) { if (it) 1f else 0f } - val b2Scale by popupTransition.animateFloat( - transitionSpec = { tween(240, if (targetState) 145 else 0) }, - label = "popupShareScale" - ) { if (it) 1f else 0f } - val b3Scale by popupTransition.animateFloat( - transitionSpec = { tween(240, if (targetState) 180 else 0) }, - label = "popupDetailsScale" - ) { if (it) 1f else 0f } - val b4Scale by popupTransition.animateFloat( - transitionSpec = { tween(240, if (targetState) 215 else 0) }, - label = "popupRemoveScale" + // `morphProgress` alone now drives the menu: GlassCapsuleMenu derives each circle's stagger + // from it, so the four per-button springs this used to run are gone. + + // Category filter for the recents list. Null = show everything. + var recentFilter by remember { mutableStateOf(null) } + var filterMenuOpen by remember { mutableStateOf(false) } + var filterAnchorY by remember { mutableStateOf(0f) } // root-space BOTTOM of the filter glyph + val filterTransition = updateTransition(filterMenuOpen, label = "recentsFilterMenu") + val filterProgress by filterTransition.animateFloat( + transitionSpec = { spring(dampingRatio = 0.82f, stiffness = 220f) }, + label = "filterProgress" ) { if (it) 1f else 0f } DisposableEffect(lifecycleOwner, context) { @@ -159,68 +194,75 @@ fun HomeScreen( } var isVisible by remember { mutableStateOf(false) } - androidx.compose.runtime.LaunchedEffect(Unit) { - isVisible = true - } - - val density = androidx.compose.ui.platform.LocalDensity.current.density - - // Progressive staggered component animations (slow fade-in) - val topBarAlpha by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 550, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "topBarAlpha" - ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 18f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 550, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "topBarOffsetY" - ) + LaunchedEffect(Unit) { isVisible = true } - val cardAlpha by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 620, delayMillis = 100, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "cardAlpha" - ) - val cardOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 24f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 620, delayMillis = 100, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "cardOffsetY" - ) + val density = LocalDensity.current.density + // One transition for the whole entrance. Previously six independent + // animateFloatAsState calls ran on three frame clocks; this is one clock with a + // per-section delay, so the sections can't drift apart on a busy frame. + val entrance = updateTransition(isVisible, label = "homeEntrance") - val recentsAlpha by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 700, delayMillis = 200, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "recentsAlpha" - ) - val recentsOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 30f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 700, delayMillis = 200, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "recentsOffsetY" - ) + val query = recentQuery.trim() + val byKind = recentFilter?.let { kind -> recents.filter { docKindOf(it.name) == kind } } ?: recents + val filtered = if (query.isBlank()) byKind + else byKind.filter { it.name.contains(query, ignoreCase = true) } + val searching = query.isNotBlank() + val visibleRecents = if (showAllRecents || searching) filtered else filtered.take(homeRecentLimit) Box(Modifier.fillMaxSize()) { - Column( - Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Box( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - } - ) { - LiquidGlassTopBar( + GlassScreenScaffold( + backdrop = backdrop, + contentHorizontalPadding = 16.dp, + headerHorizontalPadding = 16.dp, + header = { headerBackdrop -> + // Pinned above the list: the header samples the content layer, so cards scroll + // *under* it and its glass refracts them instead of only the wallpaper. + GlassSearchHeader( title = "ClearPDF", - backdrop = backdrop, + backdrop = headerBackdrop, uiSensor = uiSensor, - fontFamily = SourGummyFontFamily, - titleFontSize = 24.sp, - actions = { + query = recentQuery, + onQueryChange = { recentQuery = it }, + active = searchActive, + onActiveChange = { searchActive = it }, + searchHint = stringResource(R.string.recents_search_hint), + modifier = entrance.entranceModifier(0, density), + titleFontFamily = SourGummyFontFamily + ) + } + ) { contentPadding -> + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Welcome card. It collapses while searching so results own the fold. + item(key = "hero") { + AnimatedVisibility( + visible = !searchActive, + // Desync the fade from the height so the glass is invisible whenever its size is + // moving β€” an alpha-0 liquidGlassPanel draws nothing, so the collapse can't show + // its per-frame re-blur, and the recents panel's rise reads as a clean fade rather + // than a shimmer. Open the height first, then fade in; fade out fast, then finish + // shrinking unseen. + enter = expandVertically(tween(240)) + fadeIn(tween(200, delayMillis = 110)), + exit = fadeOut(tween(120)) + shrinkVertically(tween(240)) + ) { + Column( + Modifier + .fillMaxWidth() + .then(entrance.entranceModifier(1, density)) + .liquidGlassPanel(backdrop, uiSensor) + .padding(horizontal = 20.dp, vertical = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Rounded.Description, contentDescription = null, + tint = accent, modifier = Modifier.size(36.dp) + ) + Spacer(Modifier.height(10.dp)) + // The ON-DEVICE badge moved here from the header β€” the header is now the + // viewer's compact pill, which has no room for an action chip. Box( Modifier .clip(RoundedCornerShape(50)) @@ -232,208 +274,224 @@ fun HomeScreen( style = TextStyle(accent, 10.sp, FontWeight.Bold) ) } - } - ) - } + Spacer(Modifier.height(10.dp)) + BasicText( + stringResource(R.string.home_tagline), + style = TextStyle( + color = text, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = SourGummyFontFamily, + textAlign = TextAlign.Center + ) + ) + Spacer(Modifier.height(6.dp)) + BasicText( + stringResource(R.string.home_subtitle), + style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center) + ) + Spacer(Modifier.height(18.dp)) - // Welcome card - Column( - Modifier - .fillMaxWidth() - .graphicsLayer { - alpha = cardAlpha - translationY = cardOffsetY * density + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LiquidButton( + onClick = onNavigateToOpenPdf, + backdrop = backdrop, + tint = accent, + modifier = Modifier.weight(1f) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Rounded.FileOpen, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.home_open_pdf), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + } + } + LiquidButton( + onClick = onNavigateToScan, + backdrop = backdrop, + tint = LiquidGlassColors.Green, + modifier = Modifier.weight(1f) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Rounded.Scanner, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.home_scan), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + } + } + } } - .liquidGlassPanel(backdrop, uiSensor) - .padding(horizontal = 22.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Rounded.Description, contentDescription = null, - tint = accent, modifier = Modifier.size(50.dp) - ) - Spacer(Modifier.height(14.dp)) - BasicText( - stringResource(R.string.home_workspace_label), - style = TextStyle(accent, 11.sp, FontWeight.Bold, letterSpacing = 1.4.sp) - ) - Spacer(Modifier.height(8.dp)) - BasicText( - stringResource(R.string.home_tagline), - style = TextStyle( - color = text, - fontSize = 25.sp, - fontWeight = FontWeight.Bold, - fontFamily = SourGummyFontFamily, - textAlign = TextAlign.Center - ) - ) - Spacer(Modifier.height(8.dp)) - BasicText( - stringResource(R.string.home_subtitle), - style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center) - ) - Spacer(Modifier.height(22.dp)) + } + } - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp) + // Recent files. The rows stay inside a single glass panel β€” one refraction + // pass for the whole list rather than one per row β€” and the list is capped at + // MAX_RECENTS (20), so composing them together is cheap. + item(key = "recents") { + Column( + Modifier + .fillMaxWidth() + .then(entrance.entranceModifier(2, density)) + .liquidGlassPanel(backdrop, uiSensor) + // The container's own minimise animation. `animateContentSize` sits INSIDE the + // glass (after `liquidGlassPanel`, which is a pure draw modifier that paints at + // whatever size it measures), so the glass tracks the animated height frame by + // frame β€” the whole panel springs shut when a row leaves, rather than the row + // collapsing on its own while the panel snaps. A lightly-underdamped spring + // gives the elastic "settle" bounce; because the size delta of a single removed + // row is small, the re-blur this costs is a short, snappy window, not the long + // spring tail the old per-row collapse used to burn. + .animateContentSize( + animationSpec = spring(dampingRatio = 0.65f, stiffness = 400f) + ) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - LiquidButton( - onClick = onNavigateToOpenPdf, - backdrop = backdrop, - tint = accent, - modifier = Modifier.weight(1f) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Rounded.FileOpen, null, Modifier.size(18.dp), Color.White) - BasicText(stringResource(R.string.home_open_pdf), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.home_recents), style = TextStyle(text, 18.sp, FontWeight.Bold)) + // Category selector. One glyph, no chip row β€” the filter is a + // secondary control and shouldn't compete with the list. + if (recents.isNotEmpty()) { + val filterActive = recentFilter != null + Box( + Modifier + .size(26.dp) + .onGloballyPositioned { + val o = it.localToRoot(androidx.compose.ui.geometry.Offset.Zero) + filterAnchorY = o.y + it.size.height + } + .clip(RoundedCornerShape(50)) + .background( + if (filterActive) accent.copy(0.20f) + else if (isLight) Color.Black.copy(0.05f) else Color.White.copy(0.08f) + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { filterMenuOpen = !filterMenuOpen }, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Rounded.FilterList, + stringResource(R.string.recents_filter), + Modifier.size(15.dp), + if (filterActive) accent else sub + ) + } + } } - } - LiquidButton( - onClick = onNavigateToScan, - backdrop = backdrop, - tint = LiquidGlassColors.Green, - modifier = Modifier.weight(1f) - ) { Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Rounded.Scanner, null, Modifier.size(18.dp), Color.White) - BasicText(stringResource(R.string.home_scan), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + if (filtered.size > homeRecentLimit) { + BasicText( + if (showAllRecents) stringResource(R.string.home_see_less) else "${stringResource(R.string.home_see_all)} (${filtered.size})", + style = TextStyle(accent, 12.sp, FontWeight.SemiBold), + modifier = Modifier.clickable { showAllRecents = !showAllRecents } + ) + } + if (recents.isNotEmpty()) { + LiquidIconButton( + onClick = { + RecentFilesManager.clearRecents(context) + recents = emptyList() + showAllRecents = false + }, + backdrop = backdrop, + tint = redAccent, + modifier = Modifier.size(32.dp) + ) { + CloseCrossIcon(Modifier.size(16.dp), Color.White) + } + } } } - } - } - // Recent files - Column( - Modifier - .fillMaxWidth() - .graphicsLayer { - alpha = recentsAlpha - translationY = recentsOffsetY * density - } - .liquidGlassPanel(backdrop, uiSensor) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - BasicText(stringResource(R.string.home_recents), style = TextStyle(text, 18.sp, FontWeight.Bold)) - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (recents.isNotEmpty() && recents.size > homeRecentLimit) { + if (recents.isEmpty()) { + Column( + Modifier.fillMaxWidth().padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(36.dp), sub.copy(0.5f)) BasicText( - if (showAllRecents) stringResource(R.string.home_see_less) else "${stringResource(R.string.home_see_all)} (${recents.size})", - style = TextStyle(accent, 12.sp, FontWeight.SemiBold), - modifier = Modifier.clickable { showAllRecents = !showAllRecents } + stringResource(R.string.home_no_recents), + style = TextStyle(sub, 14.sp, FontWeight.Medium, textAlign = TextAlign.Center) + ) + BasicText( + stringResource(R.string.home_no_recents_subtitle), + style = TextStyle(sub.copy(0.7f), 12.sp, textAlign = TextAlign.Center) ) } - if (recents.isNotEmpty()) { - LiquidIconButton( - onClick = { - RecentFilesManager.clearRecents(context) - recents = emptyList() - showAllRecents = false - }, - backdrop = backdrop, - tint = redAccent, - modifier = Modifier.size(32.dp) - ) { - CloseCrossIcon(Modifier.size(16.dp), Color.White) - } + } else { + if (filtered.isEmpty()) { + BasicText( + stringResource(R.string.recents_no_matches), + style = TextStyle(sub, 13.sp), + modifier = Modifier.fillMaxWidth().padding(vertical = 14.dp), + ) } - } - } - - if (recents.isEmpty()) { - Column( - Modifier.fillMaxWidth().padding(vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(36.dp), sub.copy(0.5f)) - BasicText( - stringResource(R.string.home_no_recents), - style = TextStyle(sub, 14.sp, FontWeight.Medium, textAlign = TextAlign.Center) - ) - BasicText( - stringResource(R.string.home_no_recents_subtitle), - style = TextStyle(sub.copy(0.7f), 12.sp, textAlign = TextAlign.Center) - ) - } - } else { - val visibleRecents = if (showAllRecents) recents else recents.take(homeRecentLimit) - visibleRecents.forEach { recent -> - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(if (isLight) Color.White.copy(0.18f) else Color.White.copy(0.06f)) - .combinedClickable( - onClick = { onRecentFileSelected(recent.uri) }, - onLongClick = { selectedRecent = recent } - ) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - Modifier - .size(42.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color(0xFFE53935).copy(alpha = if (isLight) 0.14f else 0.25f)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(24.dp), Color(0xFFE53935)) - } - Column(Modifier.weight(1f)) { - BasicText( - recent.name, - style = TextStyle(text, 14.sp, FontWeight.Medium), - maxLines = 1, - overflow = TextOverflow.Ellipsis + visibleRecents.forEach { recent -> + // `key` on the URI, not the index: a swipe-removed row must take its + // offset animation state with it instead of handing it to its successor. + key(recent.uriString) { + RecentRow( + recent = recent, + isLight = isLight, + textColor = text, + secondaryColor = sub, + // Set by the long-press menu's "Remove". When it matches this row, + // the row plays the same exit as a swipe before it is dropped. + pendingDelete = pendingDeleteUri == recent.uriString, + onClick = { onRecentFileSelected(recent.uri, recent.name) }, + onLongClick = { top, height -> + selectedRecent = recent + selectedRecentAnchorY = top + selectedRecentRowHeight = height + }, + onDelete = { + // Called once the row has finished fading. Drop it from the + // list (cheap, keyed rows) β€” the container's animateContentSize + // springs the gap shut β€” and persist off the main thread so the + // terminal relayout never blocks a frame during the exit. + pendingDeleteUri = null + recents = recents.filterNot { it.uriString == recent.uriString } + homeScope.launch(Dispatchers.IO) { RecentFilesManager.removeRecent(context, recent.uri) } + } ) - val timeStr = formatTimestamp(recent.timestamp) - val sizeStr = if (recent.sizeBytes > 0) " Β· ${formatFileSize(recent.sizeBytes)}" else "" - val pageStr = if (recent.pageCount > 0) " Β· ${stringResource(R.string.recents_page_count, recent.pageCount)}" else "" - BasicText("$timeStr$sizeStr$pageStr", style = TextStyle(sub, 11.sp)) - } - - Box( - Modifier - .clip(RoundedCornerShape(6.dp)) - .background(accent.copy(0.12f)) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - BasicText(stringResource(R.string.recents_pdf_type), style = TextStyle(accent, 9.sp, FontWeight.Bold)) } } - } - if (recents.size > homeRecentLimit && !showAllRecents) { - BasicText( - stringResource(R.string.recents_long_press_hint), - style = TextStyle(sub.copy(0.6f), 11.sp, textAlign = TextAlign.Center), - modifier = Modifier.fillMaxWidth().padding(top = 4.dp) - ) + if (!searching && filtered.size > homeRecentLimit && !showAllRecents) { + BasicText( + stringResource(R.string.recents_long_press_hint), + style = TextStyle(sub.copy(0.6f), 11.sp, textAlign = TextAlign.Center), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + ) + } } } } - // Dynamic bottom spacer: tab bar (64dp) + actual nav bar inset + breathing room (20dp) - Spacer(Modifier.height( - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 84.dp - )) + // Tab bar (64dp) + breathing room (20dp). The nav-bar inset is already in + // `contentPadding`, supplied by the scaffold. + item(key = "bottomSpacer") { + Spacer(Modifier.height(84.dp)) + } + } } // ── Floating Liquid Glass Chat Bubble Reaction Bar ── @@ -442,7 +500,7 @@ fun HomeScreen( enter = fadeIn(animationSpec = spring(stiffness = Spring.StiffnessHigh)), exit = fadeOut(animationSpec = spring(stiffness = Spring.StiffnessHigh)) ) { - Box( + BoxWithConstraints( Modifier .fillMaxSize() .background(Color.Transparent) @@ -450,183 +508,141 @@ fun HomeScreen( interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, indication = null ) { selectedRecent = null }, - contentAlignment = Alignment.Center + contentAlignment = Alignment.TopCenter ) { + val maxH = constraints.maxHeight + val padPx = 16f * density selectedRecent?.let { recent -> - Column( - Modifier - .padding(horizontal = 24.dp) - .graphicsLayer { - val scaleXValue = lerp(0.20f, 1.0f, morphProgress) - val scaleYValue = lerp(0.15f, 1.0f, morphProgress) - val translateYValue = lerp(100f, 0f, morphProgress) * density - scaleX = scaleXValue - scaleY = scaleYValue - translationY = translateYValue - alpha = morphProgress.coerceIn(0f, 1f) - transformOrigin = TransformOrigin(0.5f, 0.85f) - shadowElevation = (16f * morphProgress).dp.toPx() - } - .liquidGlassPanel(backdrop, uiSensor) - .clickable( - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, - indication = null - ) { /* Consume inner taps */ } - .padding(horizontal = 18.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Chat bubble reaction header (filename & close) - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Box( - Modifier - .size(32.dp) - .clip(CircleShape) - .background(Color(0xFFE53935).copy(alpha = if (isLight) 0.14f else 0.25f)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(18.dp), Color(0xFFE53935)) - } - BasicText( - recent.name, - style = TextStyle(text, 14.sp, FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - - LiquidIconButton( - onClick = { selectedRecent = null }, - backdrop = backdrop, - surfaceColor = if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.1f), - modifier = Modifier.size(28.dp) + // Float the pill ABOVE the pressed row (WhatsApp-style); flip below only if + // there isn't enough room above. + val gap = 10f * density + val minY = padPx * 3f + val maxY = (maxH - recentPopupHeightPx - padPx * 3f).coerceAtLeast(minY) + val aboveY = selectedRecentAnchorY - recentPopupHeightPx - gap + val belowY = selectedRecentAnchorY + selectedRecentRowHeight + gap + val targetY = (if (aboveY >= minY) aboveY else belowY).coerceIn(minY, maxY) + // One glass capsule with the actions inside it, not five glass buttons floating + // in the air β€” and one blur pass instead of five. The capsule fades; the + // circles inside carry the morph (see GlassCapsuleMenu). + GlassCapsuleMenu( + actions = listOf( + GlassMenuAction( + Icons.Rounded.FileOpen, stringResource(R.string.recents_open), Color(0xFF0088FF) + ) { selectedRecent = null; onRecentFileSelected(recent.uri, recent.name) }, + GlassMenuAction( + if (recent.pinned) Icons.Rounded.PushPin else Icons.Outlined.PushPin, + stringResource(if (recent.pinned) R.string.recents_unpin else R.string.recents_pin), + Color(0xFFFF9500) ) { - CloseCrossIcon(Modifier.size(14.dp), sub) - } - } - - // Horizontal Staggered Reaction Buttons - Row( - Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - // 1. Open Button (Blue) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .graphicsLayer { - scaleX = b1Scale; scaleY = b1Scale - alpha = b1Scale.coerceIn(0f, 1f) - } + RecentFilesManager.togglePin(context, recent.uri) + recents = RecentFilesManager.getRecents(context) + selectedRecent = null + }, + GlassMenuAction( + Icons.Rounded.IosShare, stringResource(R.string.recents_share), Color(0xFF34C759) ) { - LiquidIconButton( - onClick = { - selectedRecent = null - onRecentFileSelected(recent.uri) - }, - backdrop = backdrop, - tint = Color(0xFF0088FF), - modifier = Modifier.size(50.dp) - ) { - Icon(Icons.Rounded.FileOpen, null, Modifier.size(22.dp), Color.White) + selectedRecent = null + val raw = recent.uri + // A file:// URI can't be shared to other apps (FileUriExposedException β†’ + // crash). Convert to a FileProvider content:// URI first, and use the + // file's real MIME type so non-PDF recents (xlsx/images) share correctly. + val shareUri = if (raw.scheme == "file") { + runCatching { + androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", java.io.File(raw.path!!) + ) + }.getOrNull() ?: raw + } else raw + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = context.contentResolver.getType(shareUri) ?: "application/octet-stream" + putExtra(Intent.EXTRA_STREAM, shareUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - BasicText(stringResource(R.string.recents_open), style = TextStyle(text, 11.sp, FontWeight.Medium)) - } - - // 2. Share Button (Green) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .graphicsLayer { - scaleX = b2Scale; scaleY = b2Scale - alpha = b2Scale.coerceIn(0f, 1f) - } - ) { - LiquidIconButton( - onClick = { - selectedRecent = null - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "application/pdf" - putExtra(Intent.EXTRA_STREAM, recent.uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.recents_share_pdf))) - }, - backdrop = backdrop, - tint = Color(0xFF4CAF50), - modifier = Modifier.size(50.dp) - ) { - Icon(Icons.Rounded.Share, null, Modifier.size(22.dp), Color.White) + runCatching { + context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.recents_share_pdf))) } - BasicText(stringResource(R.string.recents_share), style = TextStyle(text, 11.sp, FontWeight.Medium)) - } - - // 3. Info Button (Purple) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .graphicsLayer { - scaleX = b3Scale; scaleY = b3Scale - alpha = b3Scale.coerceIn(0f, 1f) - } + }, + GlassMenuAction( + Icons.Rounded.Info, stringResource(R.string.recents_details), Color(0xFF8E8E93) + ) { selectedRecent = null; infoRecent = recent }, + GlassMenuAction( + Icons.Rounded.DeleteOutline, stringResource(R.string.recents_remove), Color(0xFFE53935) ) { - LiquidIconButton( - onClick = { - selectedRecent = null - infoRecent = recent - }, - backdrop = backdrop, - tint = Color(0xFF9C27B0), - modifier = Modifier.size(50.dp) - ) { - Icon(Icons.Rounded.Info, null, Modifier.size(22.dp), Color.White) - } - BasicText(stringResource(R.string.recents_details), style = TextStyle(text, 11.sp, FontWeight.Medium)) + // Hand the removal to the row so it fades out and the container springs + // shut, exactly like a swipe β€” no instant snap. The row calls back to + // `onDelete` when its fade completes. + selectedRecent = null + pendingDeleteUri = recent.uriString } + ), + backdrop = backdrop, + uiSensor = uiSensor, + progress = morphProgress, + modifier = Modifier + .offset { IntOffset(0, targetY.toInt()) } + .onGloballyPositioned { recentPopupHeightPx = it.size.height } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { /* Consume inner taps */ } + ) + } + } + } - // 4. Remove Button (Red) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .graphicsLayer { - scaleX = b4Scale; scaleY = b4Scale - alpha = b4Scale.coerceIn(0f, 1f) - } - ) { - LiquidIconButton( - onClick = { - RecentFilesManager.removeRecent(context, recent.uri) - recents = recents.filterNot { it.uriString == recent.uri.toString() } - selectedRecent = null - }, - backdrop = backdrop, - tint = Color(0xFFE53935), - modifier = Modifier.size(50.dp) - ) { - CloseCrossIcon(Modifier.size(18.dp), Color.White) - } - BasicText(stringResource(R.string.recents_remove), style = TextStyle(redAccent, 11.sp, FontWeight.Medium)) - } + // ── Category filter menu ── + // Same capsule as the long-press menu, anchored just under the filter glyph. + AnimatedVisibility( + visible = filterMenuOpen, + enter = fadeIn(animationSpec = spring(stiffness = Spring.StiffnessHigh)), + exit = fadeOut(animationSpec = spring(stiffness = Spring.StiffnessHigh)) + ) { + Box( + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { filterMenuOpen = false }, + contentAlignment = Alignment.TopCenter + ) { + val neutral = if (isLight) Color(0xFF8E8E93) else Color(0xFF636366) + // Labels resolved up front: `stringResource` is @Composable, so it can't be called + // from inside a plain local helper. + val categories = listOf( + Triple(null as DocKind?, Icons.Rounded.Apps, stringResource(R.string.recents_filter_all)) to accent, + Triple(DocKind.Pdf, Icons.Rounded.PictureAsPdf, stringResource(R.string.recents_filter_pdf)) to Color(0xFFE53935), + Triple(DocKind.Word, Icons.Rounded.Description, stringResource(R.string.recents_filter_word)) to Color(0xFF2B579A), + Triple(DocKind.Excel, Icons.Rounded.GridOn, stringResource(R.string.recents_filter_excel)) to Color(0xFF217346), + Triple(DocKind.Ppt, Icons.Rounded.Slideshow, stringResource(R.string.recents_filter_ppt)) to Color(0xFFD24726), + Triple(DocKind.Image, Icons.Rounded.Image, stringResource(R.string.recents_filter_image)) to Color(0xFF7E57C2) + ) + + GlassCapsuleMenu( + actions = categories.map { (spec, tint) -> + val (kind, icon, label) = spec + GlassMenuAction( + icon, + label, + // The active category is the only one that carries its colour β€” + // selection reads at a glance without a chip row. + if (recentFilter == kind) tint else neutral + ) { + recentFilter = kind + filterMenuOpen = false + showAllRecents = false } - } - } + }, + backdrop = backdrop, + uiSensor = uiSensor, + progress = filterProgress, + modifier = Modifier + .offset { IntOffset(0, (filterAnchorY + 8f * density).toInt()) } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { /* Consume inner taps */ } + ) } } @@ -640,8 +656,12 @@ fun HomeScreen( Box( Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.45f)) - .clickable { infoRecent = null }, + // Very light dim + NO ripple, so the screen stays in focus (not greyed out). + .background(Color.Black.copy(alpha = 0.14f)) + .clickable( + interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + indication = null + ) { infoRecent = null }, contentAlignment = Alignment.Center ) { infoRecent?.let { recent -> @@ -650,7 +670,10 @@ fun HomeScreen( .fillMaxWidth() .padding(20.dp) .liquidGlassPanel(backdrop, uiSensor) - .clickable { /* Consume inner taps */ } + .clickable( + interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + indication = null + ) { /* Consume inner taps */ } .padding(22.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { @@ -689,7 +712,14 @@ fun HomeScreen( .background(if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.08f)) ) - InfoRow(stringResource(R.string.recents_pages), if (recent.pageCount > 0) recent.pageCount.toString() else stringResource(R.string.recents_unknown), text, sub) + InfoRow( + stringResource( + if (docKindOf(recent.name) == DocKind.Excel) R.string.recents_sheets + else R.string.recents_pages + ), + if (recent.pageCount > 0) recent.pageCount.toString() else stringResource(R.string.recents_unknown), + text, sub + ) InfoRow(stringResource(R.string.recents_size_label), if (recent.sizeBytes > 0) formatFileSize(recent.sizeBytes) else stringResource(R.string.recents_unknown), text, sub) InfoRow(stringResource(R.string.recents_added), formatTimestamp(recent.timestamp), text, sub) InfoRow(stringResource(R.string.recents_location), recent.uri.toString(), text, sub, maxLines = 3) @@ -709,6 +739,343 @@ fun HomeScreen( } } +/** + * Staggered entrance for one top-level section. All sections read from the same + * [Transition], so they share a frame clock and only the delay differs. + */ +@Composable +private fun Transition.entranceModifier(index: Int, density: Float): Modifier { + val alpha by animateFloat( + transitionSpec = { tween(560, delayMillis = 90 * index, easing = FastOutSlowInEasing) }, + label = "entranceAlpha" + ) { if (it) 1f else 0f } + val offsetY by animateFloat( + transitionSpec = { tween(560, delayMillis = 90 * index, easing = FastOutSlowInEasing) }, + label = "entranceOffsetY" + ) { if (it) 0f else 22f } + return Modifier.graphicsLayer { + this.alpha = alpha + translationY = offsetY * density + // Modulate alpha per draw-op instead of compositing to an offscreen buffer. The glass panels + // carry a soft `drawBackdrop` shadow that extends BEYOND their bounds; an offscreen layer + // (the default when alpha < 1) clips that overspill, so the shadow stayed invisible through + // the whole fade and then snapped in at full strength the instant alpha reached 1 and the + // offscreen switched off. Modulating avoids the buffer entirely, so the shadow fades in with + // its container β€” the way the Tools screen's panels already come in. + compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.ModulateAlpha + } +} + +/** Fraction of the row width a swipe must cross to commit the delete. */ +private const val SwipeCommitFraction = 0.40f + +/** + * How "armed" a swipe of [travel] px looks on a row [width] px wide: 0 until the swipe is well + * underway, ramping to 1 right at [SwipeCommitFraction]. Drives the delete icon's scale. + */ +private fun armProgress(travel: Float, width: Float): Float { + if (width <= 0f) return 0f + val p = (travel / width).coerceIn(0f, 1f) + return ((p - (SwipeCommitFraction - 0.22f)) / 0.22f).coerceIn(0f, 1f) +} + +/** Fraction of the row width over which the slab reaches its full red. */ +private const val RedRampFraction = 0.25f + +/** The pale red the slab starts on, so the first quarter of the swipe reads as a hint. */ +private val SwipeRedLight = Color(0xFFEF7B7B).copy(alpha = 0.55f) + +/** The committed red. Reached at [RedRampFraction] and held for the rest of the travel. */ +private val SwipeRedFull = Color(0xFFE53935) + +/** + * The slab colour for a swipe of [travel] px on a row [width] px wide. + * + * Separate from [armProgress] on purpose. The icon is allowed to hold back and then pop right at + * the commit point, but the colour is the thing you read first, and ramping it against a 0.40 + * threshold meant the row stayed almost neutral through most of the gesture and then went red in a + * rush. Full red by a quarter of the width instead: pale while you are still deciding, unambiguous + * well before you have to commit, and flat afterwards so the last half of the travel does not keep + * shifting under the finger. + */ +private fun swipeRed(travel: Float, width: Float): Color { + if (width <= 0f) return SwipeRedLight + val t = (travel / (width * RedRampFraction)).coerceIn(0f, 1f) + return lerp(SwipeRedLight, SwipeRedFull, t) +} + +/** + * Row geometry, deliberately **not** snapshot state. + * + * `onGloballyPositioned` fires for a row whenever it is re-placed, and a delete re-places every row + * below the one collapsing on every frame of the animation. Writing these into `mutableStateOf` + * therefore recomposed most of the list ~60 times a second for the length of the delete β€” each of + * those recompositions re-running `docKindOf`, the size/date formatting and the badge lookup for a + * row whose contents had not changed at all. Nothing observes these reactively: the long-press and + * the drag both read them at gesture time, long after they are set. + */ +private class RowMetrics { + var topY = 0f + var height = 0f + var width = 1f +} + +/** + * One recents entry. Liquid-glass press: no grey ripple; the row eases down with a soft + * spring while held (like an Apple button), then springs back on release. It reports its + * root-space position on long-press so the reaction pill can anchor to it. + */ +@Composable +private fun RecentRow( + recent: com.chethan616.clearpdf.data.repository.RecentFile, + isLight: Boolean, + textColor: Color, + secondaryColor: Color, + // True once the long-press menu's "Remove" targets this row: it plays the same exit a swipe does. + pendingDelete: Boolean, + onClick: () -> Unit, + onLongClick: (top: Float, height: Float) -> Unit, + onDelete: () -> Unit +) { + val metrics = remember { RowMetrics() } + val rowInteraction = remember { MutableInteractionSource() } + val rowPressed by rowInteraction.collectIsPressedAsState() + val rowScale by animateFloatAsState( + if (rowPressed) 0.96f else 1f, + spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMediumLow), + label = "recentPress" + ) + // Swipe-to-delete. Left only: a rightward drag has no meaning here, and allowing it would + // just expose empty space behind the row. + val swipeX = remember { Animatable(0f) } + // Opacity of the row AND its red slab together. The card and the slab fade as ONE object so the + // red can never outlive the card it belongs to (the old "bare red band" overlap glitch). The gap + // the row leaves behind is now closed by the container's `animateContentSize` spring, not by the + // row collapsing its own height β€” so the exit here is a single, clean fade with no layout phase + // fighting the drag, and no per-row re-blur tail. + val exitAlpha = remember { Animatable(1f) } + val scope = rememberCoroutineScope() + val view = LocalView.current + + // The single exit animation, shared by the swipe commit and the menu's "Remove". Guarded so the + // two paths can't both fire it. A quick fade, then hand back to the parent to drop the row β€” + // whereupon the container springs the gap shut. + val exiting = remember { mutableStateOf(false) } + suspend fun runExit() { + if (exiting.value) return + exiting.value = true + exitAlpha.animateTo(0f, tween(140, easing = FastOutLinearInEasing)) + onDelete() + } + LaunchedEffect(pendingDelete) { if (pendingDelete) runExit() } + + Box( + Modifier + .fillMaxWidth() + // Read inside the lambda, so the fade invalidates draw only. Wraps both the slab and + // the card, which is the whole point β€” see [exitAlpha]. The gap-close is the container's + // job now (its `animateContentSize` spring), so this row never animates its own height. + .graphicsLayer { alpha = exitAlpha.value } + .clip(RoundedCornerShape(16.dp)) + .onGloballyPositioned { + metrics.topY = it.localToRoot(androidx.compose.ui.geometry.Offset.Zero).y + metrics.height = it.size.height.toFloat() + metrics.width = it.size.width.toFloat().coerceAtLeast(1f) + } + ) { + // The delete slab behind the row. Every read of `swipeX` here happens inside a draw + // lambda, so a drag invalidates draw only β€” it never recomposes the row. + Row( + Modifier + .matchParentSize() + // Ease the slab in over the first sliver of travel instead of snapping it fully + // opaque the instant the finger moves (the old `if (swipeX < 0) 1 else 0` toggle β€” + // that hard flip, plus the pale red appearing at full strength, was the "red + // glitching" pop). Now it fades up with the drag and fades back out cleanly on a + // spring-back, so a half-swipe that is released leaves no red flash behind. + .graphicsLayer { + val travel = -swipeX.value + alpha = (travel / (metrics.width * 0.10f)).coerceIn(0f, 1f) + } + .drawBehind { + // Pale for the first quarter of the travel, solid red from there on. See + // [swipeRed] β€” the ramp is deliberately much earlier than the commit point. + drawRect(swipeRed(-swipeX.value, size.width)) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Icon( + Icons.Rounded.DeleteOutline, + stringResource(R.string.recents_swipe_delete), + Modifier + .padding(end = 20.dp) + .size(22.dp) + // Reads `swipeX` inside the layer block, so the drag invalidates draw only. + .graphicsLayer { + val travel = -swipeX.value + val w = metrics.width + val t = armProgress(travel, w) + val s = 0.82f + 0.33f * t + scaleX = s; scaleY = s + // Without this the icon is fully formed after one pixel of drag. + alpha = (travel / (w * 0.12f)).coerceIn(0f, 1f) + }, + Color.White + ) + } + + Row( + Modifier + .fillMaxWidth() + .graphicsLayer { + translationX = swipeX.value + scaleX = rowScale + scaleY = rowScale + } + .clip(RoundedCornerShape(16.dp)) + .background(if (isLight) Color.White.copy(0.18f) else Color.White.copy(0.06f)) + .combinedClickable( + interactionSource = rowInteraction, + indication = null, + onClick = onClick, + onLongClick = { onLongClick(metrics.topY, metrics.height) } + ) + // A pinned row is an explicit "keep this" β€” it doesn't swipe away. + .then( + if (recent.pinned) Modifier else Modifier.pointerInput(Unit) { + val velocityTracker = VelocityTracker() + // Both live in the gesture scope, not in composition, so neither the arming + // tick nor the drag itself recomposes the row. + var wasArmed = false + // The authoritative drag offset. Reading it back off `swipeX` instead was a + // lag bug: `snapTo` is suspending, so it lands on the next dispatch, and + // every pointer event that arrived in the same frame computed its target + // from the same stale value β€” the row fell behind a fast finger. + var offset = 0f + detectHorizontalDragGestures( + onDragStart = { + velocityTracker.resetTracking() + wasArmed = false + offset = swipeX.value + }, + onDragEnd = { + val vx = velocityTracker.calculateVelocity().x + val commitAt = metrics.width * SwipeCommitFraction + // Commit on distance OR on a fast flick. Requiring 40% of the row + // every time made a quick, confident swipe spring back. + if (-offset > commitAt || vx < -900f) { + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + scope.launch { + // Carry the swipe's momentum: keep sliding the card off to the + // left while it dissolves, so the gesture never stalls into a + // "stop, then fade" hitch. The slide and the fade run together; + // the fade (140ms, inside runExit) finishes first and hands the + // removal back to the parent, at which point the container's + // animateContentSize spring closes the gap with its bounce. The + // slab fades as one object with the card (exitAlpha), so no bare + // red is ever left shrinking on its own. + launch { swipeX.animateTo(-metrics.width, tween(190, easing = FastOutLinearInEasing)) } + runExit() + } + } else { + scope.launch { swipeX.animateTo(0f, spring(dampingRatio = 0.68f, stiffness = 420f)) } + } + }, + onDragCancel = { + scope.launch { swipeX.animateTo(0f, spring(dampingRatio = 0.68f, stiffness = 420f)) } + } + ) { change, dragAmount -> + // Consume horizontal only, so the LazyColumn keeps its vertical scroll. + change.consume() + velocityTracker.addPosition(change.uptimeMillis, change.position) + val width = metrics.width + val commitAt = width * SwipeCommitFraction + // Past the commit point the row gets heavier β€” further travel moves it + // at a third speed. The threshold becomes something you feel through the + // resistance rather than a number you cross blind. + val resisted = + if (-offset > commitAt && dragAmount < 0f) dragAmount * 0.32f else dragAmount + offset = (offset + resisted).coerceIn(-width, 0f) + val nowArmed = -offset > commitAt + if (nowArmed != wasArmed) { + wasArmed = nowArmed + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + } + val target = offset + scope.launch { swipeX.snapTo(target) } + } + } + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Icon + badge reflect the ORIGINAL file type (from its name), so a + // recents list of mixed docx/xlsx/pptx/images reads at a glance. + val kind = docKindOf(recent.name) + val (kIcon, kColor, kLabel) = kindVisual(kind) + Box( + Modifier + .size(42.dp) + .clip(RoundedCornerShape(12.dp)) + .background(kColor.copy(alpha = if (isLight) 0.14f else 0.25f)), + contentAlignment = Alignment.Center + ) { + Icon(kIcon, null, Modifier.size(23.dp), kColor) + } + Column(Modifier.weight(1f)) { + BasicText( + recent.name, + style = TextStyle(textColor, 14.sp, FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val timeStr = formatTimestamp(recent.timestamp) + val sizeStr = if (recent.sizeBytes > 0) " Β· ${formatFileSize(recent.sizeBytes)}" else "" + // A spreadsheet has sheets, not pages β€” the count is the same number, the noun isn't. + val countStr = when { + recent.pageCount <= 0 -> "" + kind == DocKind.Excel -> " Β· ${stringResource(R.string.recents_sheet_count, recent.pageCount)}" + else -> " Β· ${stringResource(R.string.recents_page_count, recent.pageCount)}" + } + BasicText("$timeStr$sizeStr$countStr", style = TextStyle(secondaryColor, 11.sp)) + } + + // Pinned marker. Contained in the same kind of translucent tinted chip the rest of the + // app uses so it reads as an intentional status badge rather than a stray glyph. The mark + // itself is a shield-with-check ("kept") β€” cleaner and more legible at this size than the + // old leaning pushpin, and it no longer needs a rotation to look deliberate. + if (recent.pinned) { + val pinOrange = Color(0xFFFF9500) + Box( + Modifier + .size(24.dp) + .clip(RoundedCornerShape(50)) + .background(pinOrange.copy(alpha = if (isLight) 0.16f else 0.24f)), + contentAlignment = Alignment.Center + ) { + Icon( + painterResource(R.drawable.ic_pinned_badge), + stringResource(R.string.recents_unpin), + Modifier.size(15.dp), + pinOrange + ) + } + } + + Box( + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(kColor.copy(0.12f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + BasicText(kLabel, style = TextStyle(kColor, 9.sp, FontWeight.Bold)) + } + } + } +} + @Composable private fun InfoRow( label: String, @@ -728,28 +1095,6 @@ private fun InfoRow( } } -@Composable -private fun ActionSheetItem( - icon: ImageVector, - label: String, - tint: Color, - textColor: Color, - onClick: () -> Unit -) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 4.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp) - ) { - Icon(icon, null, Modifier.size(22.dp), tint) - BasicText(label, style = TextStyle(textColor, 15.sp, FontWeight.Medium)) - } -} - @Composable private fun formatTimestamp(ts: Long): String { val now = System.currentTimeMillis() @@ -769,3 +1114,13 @@ private fun formatFileSize(bytes: Long): String = when { bytes < 1024 * 1024 -> stringResource(R.string.recents_size_kb, bytes / 1024) else -> stringResource(R.string.recents_size_mb, bytes / (1024.0 * 1024.0)) } + +/** Icon, accent colour, and short badge for a document kind (used by the recents rows). */ +private fun kindVisual(kind: DocKind): Triple = when (kind) { + DocKind.Pdf -> Triple(Icons.Rounded.PictureAsPdf, Color(0xFFE53935), "PDF") + DocKind.Word -> Triple(Icons.Rounded.Description, Color(0xFF2B77E5), "DOC") + DocKind.Excel -> Triple(Icons.Rounded.GridOn, Color(0xFF1E8E5A), "XLS") + DocKind.Ppt -> Triple(Icons.Rounded.Slideshow, Color(0xFFE8722B), "PPT") + DocKind.Image -> Triple(Icons.Rounded.Image, Color(0xFF8E5AF2), "IMG") + DocKind.Other -> Triple(Icons.Rounded.Description, Color(0xFF8E8E93), "FILE") +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/HtmlToPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/HtmlToPdfScreen.kt new file mode 100644 index 0000000..58d1fcb --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/HtmlToPdfScreen.kt @@ -0,0 +1,198 @@ +package com.chethan616.clearpdf.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.FileOpen +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.PictureAsPdf +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.GlassSectionHeader +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidGlassErrorCard +import com.chethan616.clearpdf.ui.components.ToolScaffold +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.HtmlToPdfViewModel +import com.chethan616.clearpdf.ui.viewmodel.WebToPdfMode +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay + +private val HtmlAccent = Color(0xFFE65100) + +@Composable +fun HtmlToPdfScreen( + backdrop: LayerBackdrop, + viewModel: HtmlToPdfViewModel, + onBack: () -> Unit, + onViewOutput: (Uri) -> Unit +) { + val state by viewModel.uiState.collectAsState() + val isDark = LocalIsDarkMode.current + val isLight = !isDark + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) + val uiSensor = rememberUISensor() + val context = LocalContext.current + + LaunchedEffect(state.resultMessage, state.errorMessage) { + if (state.resultMessage != null || state.errorMessage != null) { delay(3500); viewModel.clearFeedback() } + } + + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> if (uri != null) viewModel.onLoadFile(context, uri) } + + ToolScaffold( + title = stringResource(R.string.tool_html_to_pdf), + backdrop = backdrop, + onBack = onBack + ) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + GlassSectionHeader(title = stringResource(R.string.tool_html_to_pdf), icon = Icons.Rounded.Code, iconTint = HtmlAccent, titleColor = text) + + // Mode: Web URL / HTML + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ModeOption(stringResource(R.string.html_to_pdf_mode_web), state.mode == WebToPdfMode.URL, backdrop, isLight, { viewModel.onModeChange(WebToPdfMode.URL) }, Modifier.weight(1f)) + ModeOption(stringResource(R.string.html_to_pdf_mode_html), state.mode == WebToPdfMode.HTML, backdrop, isLight, { viewModel.onModeChange(WebToPdfMode.HTML) }, Modifier.weight(1f)) + } + + if (state.mode == WebToPdfMode.URL) { + BasicText(stringResource(R.string.html_to_pdf_url_desc), style = TextStyle(sub, 13.sp, textAlign = TextAlign.Start)) + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)) + .background(if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f)) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Rounded.Language, null, Modifier.size(18.dp), sub) + BasicTextField( + value = state.url, + onValueChange = viewModel::onUrlChange, + singleLine = true, + textStyle = TextStyle(text, 15.sp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.weight(1f), + decorationBox = { inner -> + if (state.url.isEmpty()) BasicText(stringResource(R.string.html_to_pdf_url_hint), style = TextStyle(sub, 15.sp)) + inner() + } + ) + } + } else { + BasicText(stringResource(R.string.html_to_pdf_desc), style = TextStyle(sub, 13.sp, textAlign = TextAlign.Start)) + LiquidButton(onClick = { filePicker.launch("text/html") }, backdrop = backdrop, tint = HtmlAccent, modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp)) { + Icon(Icons.Rounded.FileOpen, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.html_to_pdf_load_file), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + if (state.sourceName.isNotEmpty()) BasicText(state.sourceName, style = TextStyle(sub, 12.sp), maxLines = 1) + BasicTextField( + value = state.html, + onValueChange = viewModel::onHtmlChange, + textStyle = TextStyle(text, 13.sp), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 160.dp, max = 320.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f)) + .padding(12.dp), + decorationBox = { inner -> + if (state.html.isEmpty()) BasicText(stringResource(R.string.html_to_pdf_hint), style = TextStyle(sub, 13.sp)) + inner() + } + ) + } + } + + LiquidButton( + onClick = { if (!state.isProcessing) viewModel.convert(context) }, + backdrop = backdrop, tint = HtmlAccent, modifier = Modifier.fillMaxWidth() + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + if (state.isProcessing) CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) + else Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(18.dp), Color.White) + BasicText( + stringResource(if (state.isProcessing) R.string.html_to_pdf_working else R.string.html_to_pdf_action), + style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1 + ) + } + } + + if (state.errorMessage != null) { + LiquidGlassErrorCard(message = state.errorMessage!!, backdrop = backdrop, uiSensor = uiSensor, onDismiss = { viewModel.clearFeedback() }) + } + + state.lastOutputUri?.let { outUri -> + LiquidButton(onClick = { onViewOutput(outUri) }, backdrop = backdrop, tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth()) { + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold), modifier = Modifier.padding(vertical = 8.dp)) + } + } + + Spacer(Modifier.height(40.dp)) + } +} + +@Composable +private fun ModeOption( + label: String, + selected: Boolean, + backdrop: LayerBackdrop, + isLight: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val contentColor = if (selected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) + LiquidButton( + onClick = onClick, + backdrop = backdrop, + tint = if (selected) HtmlAccent else Color.Transparent, + surfaceColor = if (selected) HtmlAccent.copy(0.18f) else (if (isLight) Color.White.copy(0.70f) else Color.White.copy(0.10f)), + modifier = modifier + ) { + BasicText( + label, + style = TextStyle(contentColor, 14.sp, fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium), + modifier = Modifier.padding(vertical = 4.dp) + ) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImageEditorScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImageEditorScreen.kt new file mode 100644 index 0000000..a67009b --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImageEditorScreen.kt @@ -0,0 +1,210 @@ +package com.chethan616.clearpdf.ui.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.material.icons.rounded.Restore +import androidx.compose.material.icons.rounded.RotateLeft +import androidx.compose.material.icons.rounded.RotateRight +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold +import com.chethan616.clearpdf.ui.components.viewerChromeGlass +import com.chethan616.clearpdf.ui.components.viewerGlass +import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.ImageEditorViewModel +import com.chethan616.clearpdf.ui.viewmodel.ImageEditorViewModel.ImgFilter +import com.kyant.backdrop.backdrops.LayerBackdrop + +/** Real image editor: rotate, colour filters, brightness/contrast β€” applied live and saved to the + * gallery at full resolution. Operates on the original image, not a converted PDF. */ +@Composable +fun ImageEditorScreen( + backdrop: LayerBackdrop, + viewModel: ImageEditorViewModel, + onBack: () -> Unit, + onOpenPdf: (android.net.Uri) -> Unit = {} +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + val isDark = LocalIsDarkMode.current + val text = LiquidGlassColors.text(isDark) + val sub = LiquidGlassColors.secondary(isDark) + val accent = Color(0xFF8E5AF2) + val uiSensor = rememberUISensor() + + state.savedMessage?.let { msg -> + LaunchedEffect(msg) { kotlinx.coroutines.delay(2200); viewModel.dismissMessage() } + } + + GlassScreenScaffold( + backdrop = backdrop, + contentHorizontalPadding = 12.dp, + headerHorizontalPadding = 12.dp, + headerGap = 10.dp, + header = { headerBackdrop -> + GlassScreenHeaderRow( + title = state.fileName.ifBlank { stringResource(R.string.image_editor_title) }, + backdrop = headerBackdrop, + onBack = onBack + ) + } + ) { contentPadding -> + Column( + Modifier.fillMaxSize().padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + when { + state.isLoading -> Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = accent, strokeWidth = 2.5.dp) + } + state.error != null || state.preview == null -> Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + BasicText(state.error ?: "Couldn't open this image.", style = TextStyle(sub, 14.sp)) + } + else -> { + val bmp = state.preview!! + val matrix = remember(state.filter, state.brightness, state.contrast) { + ColorMatrix(ImageEditorViewModel.colorMatrixFor(state.filter, state.brightness, state.contrast)) + } + // Live preview + Box( + Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(14.dp)) + .background(if (isDark) Color(0xFF15181E) else Color(0xFFEDEEF0)), + contentAlignment = Alignment.Center + ) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.colorMatrix(matrix), + modifier = Modifier.fillMaxSize().padding(8.dp) + ) + } + + // Controls panel + Column( + Modifier.fillMaxWidth().navigationBarsPadding().viewerGlass(backdrop, viewerChromeGlass(isDark)).padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Rotate + reset + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LiquidIconButton(onClick = { viewModel.rotate(-90) }, backdrop = backdrop, surfaceColor = accent.copy(0.16f), modifier = Modifier.size(40.dp)) { + Icon(Icons.Rounded.RotateLeft, stringResource(R.string.image_rotate_left), Modifier.size(20.dp), accent) + } + LiquidIconButton(onClick = { viewModel.rotate(90) }, backdrop = backdrop, surfaceColor = accent.copy(0.16f), modifier = Modifier.size(40.dp)) { + Icon(Icons.Rounded.RotateRight, stringResource(R.string.image_rotate_right), Modifier.size(20.dp), accent) + } + Box(Modifier.weight(1f)) + LiquidIconButton(onClick = { viewModel.reset() }, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f), modifier = Modifier.size(40.dp)) { + Icon(Icons.Rounded.Restore, stringResource(R.string.image_reset_edits), Modifier.size(20.dp), sub) + } + } + + // Filter chips + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ImgFilter.entries.forEach { f -> + val selected = state.filter == f + Box( + Modifier + .clip(RoundedCornerShape(50)) + .background(if (selected) accent.copy(0.9f) else (if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f))) + .clickable { viewModel.setFilter(f) } + .padding(horizontal = 14.dp, vertical = 7.dp) + ) { + BasicText(filterLabel(f), style = TextStyle(if (selected) Color.White else text, 12.sp, FontWeight.Medium)) + } + } + } + + // Brightness + AdjustSlider(stringResource(R.string.image_brightness), state.brightness, -100f..100f, sub, text, accent) { viewModel.setBrightness(it) } + // Contrast + AdjustSlider(stringResource(R.string.image_contrast), state.contrast, 0.5f..2f, sub, text, accent) { viewModel.setContrast(it) } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LiquidButton(onClick = { viewModel.exportToPdf(context) { uri -> uri?.let(onOpenPdf) } }, backdrop = backdrop, surfaceColor = if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f), modifier = Modifier.weight(1f)) { + BasicText(stringResource(R.string.image_export_pdf), style = TextStyle(text, 14.sp, FontWeight.Medium), modifier = Modifier.padding(vertical = 6.dp)) + } + LiquidButton(onClick = { viewModel.saveToGallery(context) }, backdrop = backdrop, tint = accent, modifier = Modifier.weight(1.3f)) { + BasicText( + state.savedMessage ?: stringResource(R.string.image_save_gallery), + style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold), maxLines = 1, modifier = Modifier.padding(vertical = 6.dp) + ) + } + } + } + } + } + } + } +} + +@Composable +private fun AdjustSlider( + label: String, value: Float, range: ClosedFloatingPointRange, + sub: Color, text: Color, accent: Color, onChange: (Float) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + BasicText(label, style = TextStyle(sub, 12.sp, FontWeight.Medium)) + Slider( + value = value, onValueChange = onChange, valueRange = range, + colors = SliderDefaults.colors(thumbColor = accent, activeTrackColor = accent, inactiveTrackColor = sub.copy(0.3f)), + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun filterLabel(f: ImgFilter): String = stringResource( + when (f) { + ImgFilter.None -> R.string.image_filter_none + ImgFilter.Mono -> R.string.image_filter_mono + ImgFilter.Sepia -> R.string.image_filter_sepia + ImgFilter.Vivid -> R.string.image_filter_vivid + ImgFilter.Cool -> R.string.image_filter_cool + ImgFilter.Warm -> R.string.image_filter_warm + } +) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImageToolsScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImageToolsScreen.kt new file mode 100644 index 0000000..0cb360f --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImageToolsScreen.kt @@ -0,0 +1,213 @@ +package com.chethan616.clearpdf.ui.screen + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.PhotoLibrary +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.GlassChip +import com.chethan616.clearpdf.ui.components.GlassSectionHeader +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidGlassErrorCard +import com.chethan616.clearpdf.ui.components.LiquidSlider +import com.chethan616.clearpdf.ui.components.ToolScaffold +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.ImageToolsViewModel +import com.kyant.pdfcore.raster.PdfRasterizer.ImageFormat +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay + +private val ImageAccent = Color(0xFFF4511E) + +@Composable +fun ImageToolsScreen( + backdrop: LayerBackdrop, + viewModel: ImageToolsViewModel, + onBack: () -> Unit +) { + val state by viewModel.uiState.collectAsState() + val isDark = LocalIsDarkMode.current + val isLight = !isDark + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) + val uiSensor = rememberUISensor() + val context = LocalContext.current + + LaunchedEffect(state.resultMessage, state.errorMessage) { + if (state.errorMessage != null) { delay(3500); viewModel.clearFeedback() } + } + + val imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> if (uri != null) viewModel.onSelectImage(context, uri) } + + ToolScaffold( + title = stringResource(R.string.tool_image_tools), + backdrop = backdrop, + onBack = onBack + ) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon(Icons.Rounded.Image, null, Modifier.size(56.dp), ImageAccent) + BasicText(stringResource(R.string.tool_image_tools), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.tool_image_tools_sub), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + LiquidButton(onClick = { imagePicker.launch("image/*") }, backdrop = backdrop, tint = ImageAccent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.image_tools_pick), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + } + + if (state.sourceUri != null) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + GlassSectionHeader(title = stringResource(R.string.image_tools_options), icon = Icons.Rounded.Tune, iconTint = ImageAccent, titleColor = text) + BasicText( + "${state.sourceName} Β· ${state.srcWidth}Γ—${state.srcHeight} Β· ${state.srcSizeBytes / 1024} KB", + style = TextStyle(sub, 13.sp), maxLines = 1 + ) + + // Format + BasicText(stringResource(R.string.image_tools_format), style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + FormatOption("JPG", state.format == ImageFormat.JPEG, backdrop, isLight, { viewModel.onFormatChange(ImageFormat.JPEG) }, Modifier.weight(1f)) + FormatOption("PNG", state.format == ImageFormat.PNG, backdrop, isLight, { viewModel.onFormatChange(ImageFormat.PNG) }, Modifier.weight(1f)) + FormatOption("WebP", state.format == ImageFormat.WEBP, backdrop, isLight, { viewModel.onFormatChange(ImageFormat.WEBP) }, Modifier.weight(1f)) + } + + // Quality (lossy formats only) + if (state.format != ImageFormat.PNG) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + BasicText(stringResource(R.string.image_tools_quality), style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) + GlassChip("${state.quality}%", ImageAccent) + } + LiquidSlider( + value = { state.quality.toFloat() }, + onValueChange = { viewModel.onQualityChange(it.toInt()) }, + valueRange = 30f..100f, visibilityThreshold = 0.5f, + backdrop = backdrop, modifier = Modifier.fillMaxWidth() + ) + } + + // Resize + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + BasicText(stringResource(R.string.image_tools_resize), style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) + GlassChip("${state.scalePercent}%", ImageAccent) + } + LiquidSlider( + value = { state.scalePercent.toFloat() }, + onValueChange = { viewModel.onScaleChange(it.toInt()) }, + valueRange = 10f..100f, visibilityThreshold = 0.5f, + backdrop = backdrop, modifier = Modifier.fillMaxWidth() + ) + + BasicText(stringResource(R.string.image_tools_note), style = TextStyle(sub.copy(0.8f), 11.sp)) + } + + LiquidButton( + onClick = { if (!state.isProcessing) viewModel.process(context) }, + backdrop = backdrop, tint = ImageAccent, modifier = Modifier.fillMaxWidth() + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + if (state.isProcessing) CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) + else Icon(Icons.Rounded.Tune, null, Modifier.size(18.dp), Color.White) + BasicText( + stringResource(if (state.isProcessing) R.string.image_tools_processing else R.string.image_tools_process), + style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1 + ) + } + } + + state.result?.let { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + state.resultMessage?.let { m -> BasicText(m, style = TextStyle(text, 14.sp, fontWeight = FontWeight.Medium)) } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LiquidButton(onClick = { viewModel.saveToGallery(context) }, backdrop = backdrop, tint = ImageAccent, modifier = Modifier.weight(1f)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp)) { + Icon(Icons.Rounded.PhotoLibrary, null, Modifier.size(16.dp), Color.White) + BasicText(stringResource(R.string.image_tools_save), style = TextStyle(Color.White, 14.sp, FontWeight.Medium), maxLines = 1) + } + } + LiquidButton(onClick = { viewModel.share(context) }, backdrop = backdrop, tint = Color(0xFF1976D2), modifier = Modifier.weight(1f)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp)) { + Icon(Icons.Rounded.Share, null, Modifier.size(16.dp), Color.White) + BasicText(stringResource(R.string.image_tools_share), style = TextStyle(Color.White, 14.sp, FontWeight.Medium), maxLines = 1) + } + } + } + } + } + } + + if (state.errorMessage != null) { + LiquidGlassErrorCard(message = state.errorMessage!!, backdrop = backdrop, uiSensor = uiSensor, onDismiss = { viewModel.clearFeedback() }) + } + + Spacer(Modifier.height(40.dp)) + } +} + +@Composable +private fun FormatOption( + label: String, + selected: Boolean, + backdrop: LayerBackdrop, + isLight: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val contentColor = if (selected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) + LiquidButton( + onClick = onClick, + backdrop = backdrop, + tint = if (selected) ImageAccent else Color.Transparent, + surfaceColor = if (selected) ImageAccent.copy(0.18f) else (if (isLight) Color.White.copy(0.70f) else Color.White.copy(0.10f)), + modifier = modifier + ) { + BasicText( + label, + style = TextStyle(contentColor, 14.sp, fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium), + modifier = Modifier.padding(vertical = 4.dp) + ) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImagesToPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImagesToPdfScreen.kt index dd1dc7f..6d6572d 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImagesToPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ImagesToPdfScreen.kt @@ -47,8 +47,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.chethan616.clearpdf.ui.components.DestructiveGlassButton import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor @@ -89,11 +92,6 @@ fun ImagesToPdfScreen( animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), label = "imagesTopBarAlpha" ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 16f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "imagesTopBarOffsetY" - ) val contentAlpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (isVisible) 1f else 0f, @@ -106,28 +104,23 @@ fun ImagesToPdfScreen( label = "imagesContentOffsetY" ) - Column( - Modifier.fillMaxSize().statusBarsPadding().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(stringResource(R.string.images_to_pdf_title), backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) + GlassScreenScaffold( + backdrop = backdrop, + contentBottomPadding = 16.dp, + header = { headerBackdrop -> + // Fade only β€” the header is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = stringResource(R.string.images_to_pdf_title), + backdrop = headerBackdrop, + onBack = onBack, + modifier = Modifier.graphicsLayer { alpha = topBarAlpha } + ) } - + ) { contentPadding -> Column( Modifier - .fillMaxWidth() - .weight(1f) + .fillMaxSize() + .padding(contentPadding) .graphicsLayer { alpha = contentAlpha translationY = contentOffsetY * density @@ -140,9 +133,7 @@ fun ImagesToPdfScreen( BasicText(stringResource(R.string.images_add), style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) } if (state.imageUris.isNotEmpty()) { - LiquidButton(onClick = { viewModel.clearImages() }, backdrop = backdrop) { - BasicText(stringResource(R.string.viewer_clear), style = TextStyle(text, 14.sp, FontWeight.Medium)) - } + DestructiveGlassButton(stringResource(R.string.viewer_clear), { viewModel.clearImages() }, backdrop) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/MergePdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/MergePdfScreen.kt index ee7ef7c..cb3338c 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/MergePdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/MergePdfScreen.kt @@ -47,7 +47,8 @@ import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.ui.components.CloseCrossIcon import com.chethan616.clearpdf.ui.components.LiquidButton import com.chethan616.clearpdf.ui.components.LiquidIconButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor @@ -100,12 +101,6 @@ fun MergePdfScreen( animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), label = "mergeTopBarAlpha" ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 16f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "mergeTopBarOffsetY" - ) - val contentAlpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (isVisible) 1f else 0f, animationSpec = androidx.compose.animation.core.tween(durationMillis = 600, delayMillis = 100, easing = androidx.compose.animation.core.FastOutSlowInEasing), @@ -117,31 +112,23 @@ fun MergePdfScreen( label = "mergeContentOffsetY" ) - Column( - Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(title = stringResource(R.string.tool_merge), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) + GlassScreenScaffold( + backdrop = backdrop, + header = { headerBackdrop -> + // Fade only β€” the header is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = stringResource(R.string.tool_merge), + backdrop = headerBackdrop, + onBack = onBack, + modifier = Modifier.graphicsLayer { alpha = topBarAlpha } + ) } - + ) { contentPadding -> Column( Modifier - .fillMaxWidth() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(contentPadding) .graphicsLayer { alpha = contentAlpha translationY = contentOffsetY * density @@ -223,19 +210,25 @@ fun MergePdfScreen( } LiquidButton( - onClick = { viewModel.onMerge(context) }, - backdrop = backdrop, tint = accent, - isInteractive = canMerge + onClick = { if (canMerge) viewModel.onMerge(context) }, + backdrop = backdrop, + tint = if (canMerge) accent else accent.copy(0.35f), + modifier = Modifier.fillMaxWidth() ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp) + ) { if (state.isMerging) { CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) } else { - Icon(Icons.AutoMirrored.Rounded.MergeType, null, Modifier.size(18.dp), Color.White) + Icon(Icons.AutoMirrored.Rounded.MergeType, null, Modifier.size(18.dp), Color.White.copy(if (canMerge) 1f else 0.6f)) } BasicText( - if (state.isMerging) "Merging..." else "Merge Now", - style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium) + if (state.isMerging) stringResource(R.string.merging) else stringResource(R.string.merge_now), + style = TextStyle(Color.White.copy(if (canMerge) 1f else 0.6f), 15.sp, fontWeight = FontWeight.SemiBold), + maxLines = 1 ) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt index 031b1b7..8735463 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/OnboardingScreen.kt @@ -1,23 +1,14 @@ package com.chethan616.clearpdf.ui.screen -import androidx.compose.animation.AnimatedContent +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,600 +22,723 @@ 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.selection.selectable +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Draw -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.PictureAsPdf -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.automirrored.rounded.CallSplit +import androidx.compose.material.icons.automirrored.rounded.MergeType +import androidx.compose.material.icons.rounded.Compress +import androidx.compose.material.icons.rounded.DarkMode +import androidx.compose.material.icons.rounded.LightMode +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material.icons.rounded.PhotoLibrary +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Wallpaper import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle 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 androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp import com.chethan616.clearpdf.R -import com.chethan616.clearpdf.data.repository.OnboardingManager +import com.chethan616.clearpdf.data.repository.AppSettingsManager +import com.chethan616.clearpdf.ui.components.DemoAnnotate +import com.chethan616.clearpdf.ui.components.DemoDocumentOpen +import com.chethan616.clearpdf.ui.components.DemoFileKinds +import com.chethan616.clearpdf.ui.components.DemoReady +import com.chethan616.clearpdf.ui.components.DemoSearch +import com.chethan616.clearpdf.ui.components.DemoToolsMenu +import com.chethan616.clearpdf.ui.components.GlassMenuAction +import com.chethan616.clearpdf.ui.components.GlassMotion import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.LiquidToggle +import com.chethan616.clearpdf.ui.components.viewerChromeGlass +import com.chethan616.clearpdf.ui.components.viewerGlass import com.chethan616.clearpdf.ui.theme.LiquidGlassColors import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.shapes.Capsule +import kotlinx.coroutines.launch -private data class LanguageOption( - val code: String, - val badge: String, - val labelRes: Int -) - -private val SUPPORTED_LANGUAGES = listOf( - LanguageOption("en", "EN", R.string.language_english), - LanguageOption("pt-BR", "BR", R.string.language_portuguese) -) - -private data class FormatOption( - val labelRes: Int, - val drawableRes: Int -) - -private val FORMAT_OPTIONS = listOf( - FormatOption(R.string.file_type_pdf, R.drawable.ic_format_pdf), - FormatOption(R.string.file_type_word, R.drawable.ic_format_word), - FormatOption(R.string.file_type_excel, R.drawable.ic_format_excel), - FormatOption(R.string.file_type_powerpoint, R.drawable.ic_format_ppt), - FormatOption(R.string.file_type_images, R.drawable.ic_format_image), - FormatOption(R.string.file_type_txt, R.drawable.ic_format_txt) -) - -private data class FeatureOption( - val icon: ImageVector, - val accent: Color, - val titleRes: Int, - val descriptionRes: Int -) - -private val FEATURE_OPTIONS = listOf( - FeatureOption(Icons.Rounded.Draw, Color(0xFF7D5CFF), R.string.feature_annotate_title, R.string.feature_annotate_desc), - FeatureOption(Icons.Rounded.Search, Color(0xFF4F9BFF), R.string.feature_search_title, R.string.feature_search_desc), - FeatureOption(Icons.Rounded.Tune, Color(0xFF33C88A), R.string.feature_tools_title, R.string.feature_tools_desc) -) - -/** A quiet, editorial onboarding flow for a private document workspace. */ +private const val PageCount = 6 + +/** + * First-run tour. Six pages, each explaining a feature by **replaying the app's own animation** for + * it rather than showing a picture of it β€” see `OnboardingDemos.kt`. + * + * Two structural decisions drive the layout: + * + * **The chrome lives outside the pager.** `HorizontalPager` positions pages by layout offset, so a + * glass surface inside a page moves relative to its backdrop on every frame of a swipe, and + * `drawBackdrop` re-runs its blur and lens each time. Keeping the CTA, the dots and Skip stationary + * means a swipe only ever drags the one glass surface a page genuinely needs. + * + * **Only the current page animates.** The pager composes its neighbours ahead of time, so each demo + * is gated on `isActive` β€” otherwise all five loops run at once, off-screen, for the whole flow. + * + * The language page changes [selectedLocale] *in place*: the caller re-provides `LocalResources`, so + * every `stringResource` below re-resolves and the remaining pages translate without an Activity + * restart. That only holds while this file avoids `context.getString`, which it does. + * + * The appearance page is the same idea one step further β€” [themeMode] and [showWallpaper] are the + * app's real hoisted state, so changing them here re-tints the tour itself as you tap. That is the + * whole point of putting the page here rather than in Settings: the glass is the product, and the + * fastest way to teach it is to let the user watch it react. + */ @Composable fun OnboardingScreen( backdrop: LayerBackdrop, - onComplete: () -> Unit, - selectedLocale: String = "en", - onLanguageChanged: (String) -> Unit = {} + selectedLocale: String, + onLocaleSelected: (String) -> Unit, + themeMode: Int, + onThemeModeChanged: (Int) -> Unit, + showWallpaper: Boolean, + onShowWallpaperChanged: (Boolean) -> Unit, + hasCustomWallpaper: Boolean, + onCustomWallpaperChanged: (String?) -> Unit, + onFinish: () -> Unit ) { - val context = LocalContext.current val isDark = LocalIsDarkMode.current val uiSensor = rememberUISensor() - var page by rememberSaveable { mutableIntStateOf(0) } - val pageCount = 4 - val accents = listOf( - Color(0xFF4F7CFF), - Color(0xFFFF5F6D), - Color(0xFF7D5CFF), - Color(0xFF33C88A) - ) - val accent = accents[page] - val text = if (isDark) Color(0xFFF4F7FF) else Color(0xFF182033) - val secondary = if (isDark) Color(0xFFB3BED2) else Color(0xFF62708A) - val background = if (isDark) { - Brush.linearGradient(listOf(Color(0xFF101728), Color(0xFF080B12))) - } else { - Brush.linearGradient(listOf(Color(0xFFF5F8FF), Color(0xFFE9EEFA))) + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { PageCount }) + + val ink = LiquidGlassColors.text(isDark) + val inkSoft = LiquidGlassColors.secondary(isDark) + // Higher alpha than the viewers' chrome tint: onboarding floats over the wallpaper rather than + // over a document, and at this recipe's 2 dp blur the wallpaper comes through nearly sharp. + val glass = if (isDark) Color(0xFF20242C).copy(0.80f) else Color.White.copy(0.72f) + + val last = pagerState.currentPage == PageCount - 1 + + // Back steps through the flow. Deliberately DISABLED on page one rather than consumed there: on + // a first run that leaves the system default (exit the app, tour returns next launch), and on a + // replay from Settings it falls through to the nav host and returns to Settings. Consuming it + // would make back silently dead on the first page in both cases. + BackHandler(enabled = pagerState.currentPage > 0) { + scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } } - Box( - Modifier - .fillMaxSize() - .background(background) - .statusBarsPadding() - .navigationBarsPadding() - ) { - Box( - Modifier - .size(250.dp) - .align(Alignment.TopEnd) - .graphicsLayer { alpha = 0.22f } - .background(accent.copy(alpha = 0.28f), CircleShape) - ) - Box( - Modifier - .size(180.dp) - .align(Alignment.BottomStart) - .graphicsLayer { alpha = 0.18f } - .background(LiquidGlassColors.Blue.copy(alpha = 0.28f), CircleShape) - ) + val advance: () -> Unit = { + if (last) { + onFinish() + } else { + scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } + } + Unit + } - Column( + Box(Modifier.fillMaxSize()) { + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + val isActive = pagerState.currentPage == page + OnboardingPage( + page = page, + isActive = isActive, + backdrop = backdrop, + uiSensor = uiSensor, + glass = glass, + ink = ink, + inkSoft = inkSoft, + selectedLocale = selectedLocale, + onLocaleSelected = onLocaleSelected, + themeMode = themeMode, + onThemeModeChanged = onThemeModeChanged, + showWallpaper = showWallpaper, + onShowWallpaperChanged = onShowWallpaperChanged, + hasCustomWallpaper = hasCustomWallpaper, + onCustomWallpaperChanged = onCustomWallpaperChanged, + isDark = isDark + ) + } + + // ── Stationary chrome ─────────────────────────────────────────────────────────────────── + // Skip fades out on the last page instead of being removed, so the row above the pager does + // not reflow underneath the swipe. + val skipAlpha by animateFloatAsState( + if (last) 0f else 1f, + tween(220, easing = FastOutSlowInEasing), + label = "onboardingSkipAlpha" + ) + Row( Modifier - .fillMaxSize() - .padding(horizontal = 22.dp, vertical = 18.dp), - horizontalAlignment = Alignment.CenterHorizontally + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp) + .graphicsLayer { alpha = skipAlpha }, + verticalAlignment = Alignment.CenterVertically ) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically + if (!last) { + LiquidButton( + onClick = onFinish, + backdrop = backdrop, + // The viewers' chrome tint, not the page's `glass`. Both are the same minimal + // recipe, so the difference is purely opacity: at 0.80/0.72 the panels behind + // the demos need to hold small text, but a bare Skip capsule at that weight + // reads as a painted pill β€” the lens has nothing to refract. Dropping to the + // viewers' 0.70/0.55 is what makes it look like the buttons it is quoting. + surfaceColor = viewerChromeGlass(isDark) ) { - Box( - Modifier - .size(38.dp) - .clip(RoundedCornerShape(12.dp)) - .background(accent.copy(alpha = 0.16f)) - .border(1.dp, accent.copy(alpha = 0.30f), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(21.dp), accent) - } - Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { - BasicText( - stringResource(R.string.app_name), - style = TextStyle(text, 16.sp, FontWeight.Bold) - ) - BasicText( - stringResource(R.string.onboarding_welcome_subtitle), - style = TextStyle(secondary.copy(alpha = 0.72f), 10.sp, FontWeight.Medium) - ) - } - } - BasicText( - stringResource(R.string.onboarding_step, page + 1, pageCount), - style = TextStyle(secondary, 12.sp, FontWeight.SemiBold) - ) - } - - Row( - Modifier - .fillMaxWidth() - .padding(top = 18.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - repeat(pageCount) { index -> - val selected = index == page - val width by animateFloatAsState( - if (selected) 1f else 0.32f, - tween(260, easing = FastOutSlowInEasing), - label = "onboardingProgress$index" - ) - Box( - Modifier - .weight(if (selected) width else 0.32f) - .height(4.dp) - .clip(CircleShape) - .background(if (selected) accent else secondary.copy(alpha = 0.18f)) + BasicText( + stringResource(R.string.onboarding_skip), + style = TextStyle(ink, 14.sp, fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } + } - AnimatedContent( - targetState = page, - transitionSpec = { - val forward = targetState > initialState - if (forward) { - (slideInHorizontally { it / 3 } + fadeIn(tween(260)) + scaleIn(initialScale = 0.98f)) togetherWith - (slideOutHorizontally { -it / 4 } + fadeOut(tween(170)) + scaleOut(targetScale = 0.98f)) - } else { - (slideInHorizontally { -it / 3 } + fadeIn(tween(260)) + scaleIn(initialScale = 0.98f)) togetherWith - (slideOutHorizontally { it / 4 } + fadeOut(tween(170)) + scaleOut(targetScale = 0.98f)) - } - }, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - label = "onboardingPage" - ) { currentPage -> - when (currentPage) { - 0 -> LanguagePage( - backdrop = backdrop, - uiSensor = uiSensor, - isDark = isDark, - text = text, - secondary = secondary, - selectedLocale = selectedLocale, - onLanguageSelected = onLanguageChanged - ) - 1 -> FormatsPage(backdrop, uiSensor, isDark, text, secondary) - 2 -> FeaturesPage(backdrop, uiSensor, isDark, text, secondary) - else -> ReadyPage(backdrop, uiSensor, isDark, text, secondary) - } - } + Column( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 28.dp) + .padding(bottom = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + PageDots(current = pagerState.currentPage, count = PageCount, ink = ink) - Spacer(Modifier.height(14.dp)) + // `ink`, not `inkSoft`. Everything in this Column sits directly on the wallpaper with no + // glass under it, and the secondary token is a mid grey that has to survive on whatever + // photo is behind it. Full-strength ink (near-white on dark, near-black on light) is the + // only value that holds in both, so the hierarchy is carried by size and weight instead. + BasicText( + stringResource(R.string.onboarding_step, pagerState.currentPage + 1, PageCount), + style = TextStyle(ink.copy(0.8f), 12.sp, fontWeight = FontWeight.Medium), + maxLines = 1 + ) - // Keep the proven Backdrop button as the only primary action. LiquidButton( - onClick = { - if (page < pageCount - 1) page++ - else { - OnboardingManager.setOnboardingComplete(context) - onComplete() - } - }, + onClick = advance, backdrop = backdrop, - tint = accent, + tint = Color(0xFF0088FF), modifier = Modifier.fillMaxWidth() ) { - BasicText( - stringResource( - if (page == pageCount - 1) R.string.onboarding_cta_get_started - else R.string.onboarding_cta_continue - ), - style = TextStyle(Color.White, 16.sp, FontWeight.SemiBold), - modifier = Modifier.padding(vertical = 10.dp) + // Cross-faded rather than swapped: the label changes on the last page and a hard + // swap inside a capsule that is not resizing reads as a glitch. + val ctaProgress by animateFloatAsState( + if (last) 1f else 0f, + GlassMotion.fade(), + label = "onboardingCta" ) + Box(contentAlignment = Alignment.Center) { + if (ctaProgress < 0.999f) { + BasicText( + stringResource(R.string.onboarding_cta_continue), + style = TextStyle(Color.White, 16.sp, fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.graphicsLayer { alpha = 1f - ctaProgress } + ) + } + if (ctaProgress > 0.001f) { + BasicText( + stringResource(R.string.onboarding_cta_get_started), + style = TextStyle(Color.White, 16.sp, fontWeight = FontWeight.Bold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.graphicsLayer { alpha = ctaProgress } + ) + } + } } } } } +/** Flat pills β€” no glass β€” so the active one can stretch without costing a blur pass. */ +@Composable +private fun PageDots(current: Int, count: Int, ink: Color) { + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + repeat(count) { i -> + val active = i == current + val w by animateFloatAsState( + if (active) 22f else 7f, + GlassMotion.settle(), + label = "dotWidth$i" + ) + val a by animateFloatAsState( + if (active) 0.9f else 0.28f, + GlassMotion.fade(), + label = "dotAlpha$i" + ) + Box( + Modifier + .width(w.dp) + .height(7.dp) + .clip(CircleShape) + .background(ink.copy(a)) + ) + } + } +} + @Composable -private fun LanguagePage( +private fun OnboardingPage( + page: Int, + isActive: Boolean, backdrop: LayerBackdrop, uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - isDark: Boolean, - text: Color, - secondary: Color, + glass: Color, + ink: Color, + inkSoft: Color, selectedLocale: String, - onLanguageSelected: (String) -> Unit + onLocaleSelected: (String) -> Unit, + themeMode: Int, + onThemeModeChanged: (Int) -> Unit, + showWallpaper: Boolean, + onShowWallpaperChanged: (Boolean) -> Unit, + hasCustomWallpaper: Boolean, + onCustomWallpaperChanged: (String?) -> Unit, + isDark: Boolean ) { + val title: String + val subtitle: String + when (page) { + 0 -> { title = stringResource(R.string.onboarding_welcome_title); subtitle = stringResource(R.string.onboarding_welcome_subtitle) } + 1 -> { title = stringResource(R.string.onboarding_language_title); subtitle = stringResource(R.string.onboarding_language_subtitle) } + 2 -> { title = stringResource(R.string.onboarding_appearance_title); subtitle = stringResource(R.string.onboarding_appearance_subtitle) } + 3 -> { title = stringResource(R.string.onboarding_files_title); subtitle = stringResource(R.string.onboarding_files_subtitle) } + 4 -> { title = stringResource(R.string.onboarding_features_title); subtitle = stringResource(R.string.onboarding_features_subtitle) } + else -> { title = stringResource(R.string.onboarding_ready_title); subtitle = stringResource(R.string.onboarding_ready_subtitle) } + } + + // Copy rises in behind the demo on every visit, so paging back and forth feels alive rather than + // landing on a static slab. Draw-time properties only. + val enter by animateFloatAsState( + if (isActive) 1f else 0f, + tween(360, easing = FastOutSlowInEasing), + label = "pageEnter" + ) + val density = LocalDensity.current.density + Column( Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally + .statusBarsPadding() + // Top clears the Skip capsule; bottom clears the dots + step counter + CTA. + .padding(horizontal = 32.dp) + .padding(top = 56.dp, bottom = 170.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - OnboardingArtworkCard(backdrop, uiSensor, R.drawable.ic_onboarding_language, Color(0xFF4F7CFF), isDark) - Spacer(Modifier.height(22.dp)) - BasicText( - stringResource(R.string.onboarding_welcome_title), - style = TextStyle(text, 29.sp, FontWeight.Bold, textAlign = TextAlign.Center) - ) - Spacer(Modifier.height(8.dp)) - BasicText( - stringResource(R.string.onboarding_welcome_subtitle), - style = TextStyle(secondary, 15.sp, FontWeight.Medium, textAlign = TextAlign.Center) - ) - Spacer(Modifier.height(22.dp)) - BasicText( - stringResource(R.string.onboarding_language_label), - style = TextStyle(secondary.copy(alpha = 0.86f), 12.sp, FontWeight.SemiBold) - ) - Spacer(Modifier.height(9.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + when (page) { + 0 -> DemoDocumentOpen(isActive, backdrop, glass, ink) + 1 -> LanguageChooser(backdrop, glass, ink, inkSoft, selectedLocale, onLocaleSelected, isDark) + 2 -> AppearanceChooser( + backdrop, glass, ink, inkSoft, isDark, + themeMode, onThemeModeChanged, showWallpaper, onShowWallpaperChanged, + hasCustomWallpaper, onCustomWallpaperChanged + ) + 3 -> DemoFileKinds(isActive, backdrop, glass, ink, inkSoft) + 4 -> FeatureRows(isActive, backdrop, uiSensor, glass, ink, inkSoft) + else -> DemoReady(isActive, backdrop, glass) + } + } + + Spacer(Modifier.height(28.dp)) + Column( - Modifier - .fillMaxWidth() - .liquidGlassPanel(backdrop, uiSensor) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + Modifier.graphicsLayer { + alpha = enter + translationY = lerp(16f, 0f, enter) * density + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) ) { - SUPPORTED_LANGUAGES.forEach { language -> - val selected = selectedLocale == language.code - val rowColor = if (selected) Color(0xFF4F7CFF).copy(alpha = 0.20f) else Color.White.copy(if (isDark) 0.06f else 0.48f) - Row( + BasicText( + title, + style = TextStyle(ink, 27.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + ) + // Also `ink` β€” see the step counter's note. This block is the largest run of text in the + // app with nothing but wallpaper behind it. + BasicText( + subtitle, + style = TextStyle(ink.copy(0.86f), 15.sp, textAlign = TextAlign.Center, lineHeight = 21.sp) + ) + if (page == PageCount - 1) { + Spacer(Modifier.height(4.dp)) + Box( Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(rowColor) - .border( - 1.dp, - if (selected) Color(0xFF4F7CFF) else secondary.copy(alpha = 0.16f), - RoundedCornerShape(16.dp) - ) - .selectable( - selected = selected, - role = Role.RadioButton, - onClick = { onLanguageSelected(language.code) } - ) - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + .viewerGlass(backdrop, glass, shape = { Capsule }) + .padding(horizontal = 14.dp, vertical = 8.dp) ) { - Box( - Modifier - .size(38.dp) - .clip(RoundedCornerShape(11.dp)) - .background(Color(0xFF4F7CFF).copy(alpha = if (selected) 0.28f else 0.12f)), - contentAlignment = Alignment.Center - ) { - BasicText(language.badge, style = TextStyle(Color(0xFF4F7CFF), 12.sp, FontWeight.Bold)) - } BasicText( - stringResource(language.labelRes), - style = TextStyle(text, 15.sp, if (selected) FontWeight.Bold else FontWeight.Medium), - modifier = Modifier.weight(1f) + stringResource(R.string.onboarding_privacy_badge), + style = TextStyle(inkSoft, 11.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center) ) - if (selected) { - Box( - Modifier - .size(23.dp) - .clip(CircleShape) - .background(Color(0xFF4F7CFF)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.Check, null, Modifier.size(14.dp), Color.White) - } - } } } } } } +/** + * Page 2 is the only interactive one β€” the real segmented control from Settings, not a replay. + * + * Selecting here does **not** restart the Activity; the caller updates the hoisted locale state and + * re-provides `LocalResources`, so this whole screen re-composes translated. The restart, if the + * choice actually changed anything, is deferred to "Get Started". + */ @Composable -private fun FormatsPage( +private fun LanguageChooser( backdrop: LayerBackdrop, - uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - isDark: Boolean, - text: Color, - secondary: Color + glass: Color, + ink: Color, + inkSoft: Color, + selectedLocale: String, + onLocaleSelected: (String) -> Unit, + isDark: Boolean ) { + val accent = Color(0xFF0088FF) Column( Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .viewerGlass(backdrop, glass) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) ) { - OnboardingArtworkCard(backdrop, uiSensor, R.drawable.ic_onboarding_formats, Color(0xFFFF5F6D), isDark) - Spacer(Modifier.height(22.dp)) - BasicText( - stringResource(R.string.onboarding_files_title), - style = TextStyle(text, 29.sp, FontWeight.Bold, textAlign = TextAlign.Center) - ) - Spacer(Modifier.height(8.dp)) BasicText( - stringResource(R.string.onboarding_files_subtitle), - style = TextStyle(secondary, 15.sp, FontWeight.Medium, textAlign = TextAlign.Center) + stringResource(R.string.onboarding_language_label), + style = TextStyle(inkSoft, 13.sp, fontWeight = FontWeight.SemiBold) ) - Spacer(Modifier.height(22.dp)) - Column( - Modifier - .fillMaxWidth() - .liquidGlassPanel(backdrop, uiSensor) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(9.dp) - ) { - FORMAT_OPTIONS.chunked(3).forEach { row -> - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(9.dp)) { - row.forEach { option -> - FormatChip(option, isDark, text, Modifier.weight(1f)) - } - } + listOf("en" to R.string.language_english, "pt-BR" to R.string.language_portuguese).forEach { (code, res) -> + val selected = selectedLocale == code + LiquidButton( + onClick = { onLocaleSelected(code) }, + backdrop = backdrop, + tint = if (selected) accent else Color.Unspecified, + surfaceColor = if (selected) Color.Unspecified else (if (isDark) Color.White.copy(0.10f) else Color.Black.copy(0.06f)), + modifier = Modifier.fillMaxWidth() + ) { + BasicText( + stringResource(res), + style = TextStyle( + if (selected) Color.White else ink, + 15.sp, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } } } +/** + * Page 3 β€” theme and background, the second interactive page. + * + * Both controls write straight through to the app's real hoisted state, so the tour re-themes under + * the user's finger: the panel they just tapped is itself glass over the wallpaper, which makes this + * the one place in the app where the setting and its preview are the same pixels. Turning the + * background off here is also the honest way to show what that does β€” the demos on the later pages + * are the exact surfaces affected. + * + * Mirrors `SettingsScreen`'s appearance section (`:279-320`, `:578-587`) rather than sharing code + * with it: that section is embedded in a scrolling settings layout with its own panel chrome and + * cascade offsets, and lifting it out would mean parameterising it for two very different hosts. + */ @Composable -private fun FormatChip( - option: FormatOption, +private fun AppearanceChooser( + backdrop: LayerBackdrop, + glass: Color, + ink: Color, + inkSoft: Color, isDark: Boolean, - text: Color, - modifier: Modifier + themeMode: Int, + onThemeModeChanged: (Int) -> Unit, + showWallpaper: Boolean, + onShowWallpaperChanged: (Boolean) -> Unit, + hasCustomWallpaper: Boolean, + onCustomWallpaperChanged: (String?) -> Unit ) { - Column( - modifier - .clip(RoundedCornerShape(15.dp)) - .background(if (isDark) Color.White.copy(alpha = 0.07f) else Color.White.copy(alpha = 0.62f)) - .border(1.dp, if (isDark) Color.White.copy(0.10f) else Color.Black.copy(0.07f), RoundedCornerShape(15.dp)) - .padding(vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Image( - painterResource(option.drawableRes), - contentDescription = stringResource(option.labelRes), - modifier = Modifier.size(35.dp) - ) - BasicText( - stringResource(option.labelRes), - style = TextStyle(text, 11.sp, FontWeight.SemiBold, textAlign = TextAlign.Center) - ) + val context = LocalContext.current + // Same contract Settings uses: `OpenDocument` plus a persisted read grant, because the chosen + // image has to survive the restart that finishing the tour may trigger. + val wallpaperPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + if (uri != null) { + runCatching { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + AppSettingsManager.setCustomWallpaper(context, uri.toString()) + onCustomWallpaperChanged(uri.toString()) + } } -} -@Composable -private fun FeaturesPage( - backdrop: LayerBackdrop, - uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - isDark: Boolean, - text: Color, - secondary: Color -) { + data class ThemeOption(val idx: Int, val label: String, val icon: ImageVector, val accent: Color) + val options = listOf( + ThemeOption(0, stringResource(R.string.settings_theme_auto), Icons.Rounded.PhoneAndroid, Color(0xFF0088FF)), + ThemeOption(1, stringResource(R.string.settings_theme_light), Icons.Rounded.LightMode, Color(0xFFFFA726)), + ThemeOption(2, stringResource(R.string.settings_theme_dark), Icons.Rounded.DarkMode, Color(0xFF7C4DFF)) + ) + + // Tighter than the language page's 20/14 on purpose. This panel now carries six rows, and the + // demo area it sits in is a `weight(1f)` box between fixed top and bottom padding β€” around + // 275 dp on a 640 dp-tall phone. At 20/14 the gallery row pushed it past that and the panel + // clipped rather than the page scrolling. Column( Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .viewerGlass(backdrop, glass) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - OnboardingArtworkCard(backdrop, uiSensor, R.drawable.ic_onboarding_tools, Color(0xFF7D5CFF), isDark) - Spacer(Modifier.height(22.dp)) BasicText( - stringResource(R.string.onboarding_features_title), - style = TextStyle(text, 29.sp, FontWeight.Bold, textAlign = TextAlign.Center) + stringResource(R.string.settings_appearance), + style = TextStyle(inkSoft, 13.sp, fontWeight = FontWeight.SemiBold) ) - Spacer(Modifier.height(8.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { option -> + val selected = themeMode == option.idx + val cc = if (selected) Color.White else ink + LiquidButton( + onClick = { onThemeModeChanged(option.idx) }, + backdrop = backdrop, + tint = if (selected) option.accent else Color.Unspecified, + surfaceColor = if (selected) Color.Unspecified else (if (isDark) Color.White.copy(0.10f) else Color.Black.copy(0.06f)), + modifier = Modifier.weight(1f) + ) { + // Icon above label rather than beside it: three segments across a 32dp-inset page + // leaves ~80dp each, and "Escuro"/"Claro" next to a 16dp icon clips in pt-BR. + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(option.icon, null, Modifier.size(18.dp), cc) + BasicText( + option.label, + style = TextStyle(cc, 12.sp, fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + BasicText( - stringResource(R.string.onboarding_features_subtitle), - style = TextStyle(secondary, 15.sp, FontWeight.Medium, textAlign = TextAlign.Center) + when (themeMode) { + 1 -> stringResource(R.string.settings_theme_light_desc) + 2 -> stringResource(R.string.settings_theme_dark_desc) + else -> stringResource(R.string.settings_theme_auto_desc) + }, + style = TextStyle(inkSoft.copy(0.8f), 12.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - Spacer(Modifier.height(22.dp)) - Column( + + Box(Modifier.fillMaxWidth().height(1.dp).background(ink.copy(0.10f))) + + Row( Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(9.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - FEATURE_OPTIONS.forEach { feature -> - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(17.dp)) - .background(feature.accent.copy(alpha = if (isDark) 0.12f else 0.08f)) - .border(1.dp, feature.accent.copy(alpha = 0.22f), RoundedCornerShape(17.dp)) - .padding(horizontal = 13.dp, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + Icon(Icons.Rounded.Wallpaper, null, Modifier.size(20.dp), inkSoft) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + BasicText( + stringResource(R.string.settings_background), + style = TextStyle(ink, 14.sp, fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + BasicText( + stringResource(R.string.settings_background_desc), + style = TextStyle(inkSoft, 12.sp, lineHeight = 16.sp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + LiquidToggle( + selected = { showWallpaper }, + onSelect = onShowWallpaperChanged, + backdrop = backdrop + ) + } + + // Only while the background is actually on, and only one control wide β€” the brief was "don't + // make it too cluttered", and a picker for a background that is switched off is a row the + // user has to read past to find out it does nothing. Reset joins it only once there is + // something to reset, so the common case stays a single button. + if (showWallpaper) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + LiquidButton( + onClick = { wallpaperPicker.launch(arrayOf("image/*")) }, + backdrop = backdrop, + surfaceColor = if (isDark) Color.White.copy(0.10f) else Color.Black.copy(0.06f), + modifier = Modifier.weight(1f) ) { - Box( - Modifier - .size(40.dp) - .clip(RoundedCornerShape(12.dp)) - .background(feature.accent.copy(alpha = 0.20f)), - contentAlignment = Alignment.Center + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(7.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically ) { - Icon(feature.icon, null, Modifier.size(21.dp), feature.accent) + Icon(Icons.Rounded.PhotoLibrary, null, Modifier.size(15.dp), ink) + BasicText( + stringResource(R.string.settings_bg_gallery), + style = TextStyle(ink, 13.sp, fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - BasicText(stringResource(feature.titleRes), style = TextStyle(text, 14.sp, FontWeight.Bold)) - BasicText(stringResource(feature.descriptionRes), style = TextStyle(secondary, 11.sp, FontWeight.Medium)) + } + if (hasCustomWallpaper) { + LiquidIconButton( + onClick = { + AppSettingsManager.clearCustomWallpaper(context) + onCustomWallpaperChanged(null) + }, + backdrop = backdrop, + surfaceColor = if (isDark) Color.White.copy(0.10f) else Color.Black.copy(0.06f), + modifier = Modifier.size(40.dp) + ) { + Icon(Icons.Rounded.Refresh, stringResource(R.string.settings_reset), Modifier.size(17.dp), inkSoft) } - Icon(Icons.Rounded.Check, null, Modifier.size(18.dp), feature.accent) } } } } } +/** + * Page 5: the three headline features. The first two are compact rows with a micro-demo in their + * leading slot; the third is a full-width showcase. + * + * **Why the third one breaks the row shape.** `GlassCapsuleMenu` is a horizontal `Row` of 40dp + * circles β€” 152dp wide with three actions. A 72dp leading slot constrains it to 72dp, and a `Row` + * that runs out of width does not wrap or shrink, it measures the overflow at zero: the demo + * rendered as a lone Merge button with the other two actions silently gone. Scaling it down with a + * `graphicsLayer` would not have fixed the measurement either, and would have composited the glass + * at less than half size so its blur and lens read at the wrong scale. Giving it the panel's full + * width instead lets the real component measure at its real size, which is the only way this demo is + * worth showing at all β€” it is the one production animation onboarding plays literally. + * + * All of it shares one glass panel: four separate glass surfaces would cost four blur passes for no + * visual gain, the same reasoning `GlassCapsuleMenu` and `ToolSectionPanel` already follow. + */ @Composable -private fun ReadyPage( +private fun FeatureRows( + isActive: Boolean, backdrop: LayerBackdrop, uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - isDark: Boolean, - text: Color, - secondary: Color + glass: Color, + ink: Color, + inkSoft: Color ) { + val toolActions = remember { + listOf( + GlassMenuAction(Icons.AutoMirrored.Rounded.MergeType, "Merge", LiquidGlassColors.Red) {}, + GlassMenuAction(Icons.AutoMirrored.Rounded.CallSplit, "Split", LiquidGlassColors.Purple) {}, + GlassMenuAction(Icons.Rounded.Compress, "Compress", LiquidGlassColors.Green) {} + ) + } + Column( Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .viewerGlass(backdrop, glass) + .padding(vertical = 14.dp, horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - OnboardingArtworkCard(backdrop, uiSensor, R.drawable.ic_onboarding_ready, Color(0xFF33C88A), isDark) - Spacer(Modifier.height(26.dp)) - BasicText( - stringResource(R.string.onboarding_ready_title), - style = TextStyle(text, 30.sp, FontWeight.Bold, textAlign = TextAlign.Center) - ) - Spacer(Modifier.height(10.dp)) - BasicText( - stringResource(R.string.onboarding_ready_subtitle), - style = TextStyle(secondary, 15.sp, FontWeight.Medium, textAlign = TextAlign.Center) - ) - Spacer(Modifier.height(28.dp)) - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(Color(0xFF33C88A).copy(alpha = if (isDark) 0.12f else 0.09f)) - .border(1.dp, Color(0xFF33C88A).copy(alpha = 0.24f), RoundedCornerShape(18.dp)) - .padding(horizontal = 15.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - Modifier - .size(38.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color(0xFF33C88A).copy(alpha = 0.20f)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.Lock, null, Modifier.size(20.dp), Color(0xFF33C88A)) - } + FeatureRow( + title = stringResource(R.string.feature_annotate_title), + desc = stringResource(R.string.feature_annotate_desc), + ink = ink, inkSoft = inkSoft + ) { DemoAnnotate(isActive, ink) } + + FeatureRow( + title = stringResource(R.string.feature_search_title), + desc = stringResource(R.string.feature_search_desc), + ink = ink, inkSoft = inkSoft + ) { DemoSearch(isActive, backdrop, glass, ink) } + + // A hairline before the shape changes, so the wider third entry reads as deliberate emphasis + // rather than a row that failed to line up with the two above it. + Box(Modifier.fillMaxWidth().height(1.dp).background(ink.copy(0.10f))) + + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + BasicText( + stringResource(R.string.feature_tools_title), + style = TextStyle(ink, 15.sp, fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) BasicText( - stringResource(R.string.onboarding_privacy_badge), - style = TextStyle(text, 12.sp, FontWeight.SemiBold) + stringResource(R.string.feature_tools_desc), + style = TextStyle(inkSoft, 12.5.sp, lineHeight = 17.sp), + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } + Box( + Modifier.fillMaxWidth().padding(top = 2.dp), + contentAlignment = Alignment.Center + ) { + DemoToolsMenu(isActive, backdrop, uiSensor, glass, toolActions) + } } } @Composable -private fun OnboardingArtworkCard( - backdrop: LayerBackdrop, - uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - drawableRes: Int, - accent: Color, - isDark: Boolean +private fun FeatureRow( + title: String, + desc: String, + ink: Color, + inkSoft: Color, + demo: @Composable () -> Unit ) { - val transition = rememberInfiniteTransition(label = "onboardingArtwork") - val drift by transition.animateFloat( - initialValue = -3f, - targetValue = 3f, - animationSpec = infiniteRepeatable( - animation = tween(2400, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "artworkDrift" - ) - - Box( - Modifier - .fillMaxWidth() - .height(208.dp) - .liquidGlassPanel(backdrop, uiSensor) - .clip(RoundedCornerShape(26.dp)), - contentAlignment = Alignment.Center + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) ) { - Box( - Modifier - .size(165.dp) - .align(Alignment.Center) - .clip(CircleShape) - .background(accent.copy(alpha = if (isDark) 0.16f else 0.10f)) - ) - Box( - Modifier - .size(116.dp) - .align(Alignment.Center) - .clip(CircleShape) - .background(Color.White.copy(alpha = if (isDark) 0.05f else 0.42f)) - ) - Image( - painterResource(drawableRes), - contentDescription = null, - modifier = Modifier - .size(230.dp) - .graphicsLayer { translationY = drift } - ) - Box( - Modifier - .align(Alignment.TopEnd) - .padding(15.dp) - .size(8.dp) - .clip(CircleShape) - .background(accent) - ) + demo() + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + BasicText( + title, + style = TextStyle(ink, 15.sp, fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + BasicText( + desc, + style = TextStyle(inkSoft, 12.5.sp, lineHeight = 17.sp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PageNumbersScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PageNumbersScreen.kt new file mode 100644 index 0000000..b8fa76f --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PageNumbersScreen.kt @@ -0,0 +1,185 @@ +package com.chethan616.clearpdf.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.GlassSectionHeader +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidGlassErrorCard +import com.chethan616.clearpdf.ui.components.LiquidToggle +import com.chethan616.clearpdf.ui.components.ToolScaffold +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.PageNumbersViewModel +import com.kyant.pdfcore.pagenumber.PdfPageNumberer +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay + +private val PageNumAccent = Color(0xFF3949AB) + +@Composable +fun PageNumbersScreen( + backdrop: LayerBackdrop, + viewModel: PageNumbersViewModel, + onBack: () -> Unit, + onViewOutput: (Uri) -> Unit +) { + val state by viewModel.uiState.collectAsState() + val isDark = LocalIsDarkMode.current + val isLight = !isDark + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) + val uiSensor = rememberUISensor() + val context = LocalContext.current + + LaunchedEffect(state.resultMessage, state.errorMessage) { + if (state.resultMessage != null || state.errorMessage != null) { delay(3500); viewModel.clearFeedback() } + } + + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> if (uri != null) viewModel.onSelectFile(context, uri) } + + ToolScaffold( + title = stringResource(R.string.tool_page_numbers), + backdrop = backdrop, + onBack = onBack + ) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon(Icons.Rounded.Numbers, null, Modifier.size(56.dp), PageNumAccent) + BasicText(stringResource(R.string.tool_page_numbers), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.tool_page_numbers_sub), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + LiquidButton(onClick = { filePicker.launch(arrayOf("application/pdf")) }, backdrop = backdrop, tint = PageNumAccent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + } + + if (state.sourceUri != null) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + GlassSectionHeader( + title = stringResource(R.string.page_numbers_position), + icon = Icons.Rounded.Numbers, iconTint = PageNumAccent, titleColor = text + ) + BasicText(state.sourceName, style = TextStyle(sub, 13.sp), maxLines = 1) + + // Position selector (Center / Right) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PositionOption( + label = stringResource(R.string.page_numbers_center), + selected = state.position == PdfPageNumberer.Position.CENTER, + backdrop = backdrop, isLight = isLight, + onClick = { viewModel.onPositionChange(PdfPageNumberer.Position.CENTER) }, + modifier = Modifier.weight(1f) + ) + PositionOption( + label = stringResource(R.string.page_numbers_right), + selected = state.position == PdfPageNumberer.Position.RIGHT, + backdrop = backdrop, isLight = isLight, + onClick = { viewModel.onPositionChange(PdfPageNumberer.Position.RIGHT) }, + modifier = Modifier.weight(1f) + ) + } + + // Include total toggle + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Column(Modifier.weight(1f)) { + BasicText(stringResource(R.string.page_numbers_total), style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.page_numbers_total_desc), style = TextStyle(sub, 12.sp)) + } + LiquidToggle(selected = { state.includeTotal }, onSelect = viewModel::onIncludeTotalChange, backdrop = backdrop) + } + } + + LiquidButton( + onClick = { if (!state.isProcessing) viewModel.apply(context) }, + backdrop = backdrop, tint = PageNumAccent, modifier = Modifier.fillMaxWidth() + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + if (state.isProcessing) CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) + else Icon(Icons.Rounded.Numbers, null, Modifier.size(18.dp), Color.White) + BasicText( + stringResource(if (state.isProcessing) R.string.page_numbers_working else R.string.page_numbers_action), + style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1 + ) + } + } + } + + if (state.errorMessage != null) { + LiquidGlassErrorCard(message = state.errorMessage!!, backdrop = backdrop, uiSensor = uiSensor, onDismiss = { viewModel.clearFeedback() }) + } + + state.lastOutputUri?.let { outUri -> + LiquidButton(onClick = { onViewOutput(outUri) }, backdrop = backdrop, tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth()) { + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold), modifier = Modifier.padding(vertical = 8.dp)) + } + } + + Spacer(Modifier.height(40.dp)) + } +} + +@Composable +private fun PositionOption( + label: String, + selected: Boolean, + backdrop: LayerBackdrop, + isLight: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val contentColor = if (selected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) + LiquidButton( + onClick = onClick, + backdrop = backdrop, + tint = if (selected) PageNumAccent else Color.Transparent, + surfaceColor = if (selected) PageNumAccent.copy(0.18f) else (if (isLight) Color.White.copy(0.70f) else Color.White.copy(0.10f)), + modifier = modifier + ) { + BasicText( + label, + style = TextStyle(contentColor, 14.sp, fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium), + modifier = Modifier.padding(vertical = 4.dp) + ) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PageOrganizerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PageOrganizerScreen.kt index e6f3a67..ea890fb 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PageOrganizerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PageOrganizerScreen.kt @@ -65,7 +65,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.LiquidIconButton import com.chethan616.clearpdf.ui.components.LiquidSaveDialog import com.chethan616.clearpdf.ui.components.liquidGlassPanel @@ -104,11 +105,6 @@ fun PageOrganizerScreen( animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), label = "organizerTopBarAlpha" ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 16f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "organizerTopBarOffsetY" - ) val contentAlpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (isVisible) 1f else 0f, @@ -148,28 +144,23 @@ fun PageOrganizerScreen( uri?.let { viewModel.onSelectFile(context, it) } } - Column( - Modifier.fillMaxSize().statusBarsPadding().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density.density - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(stringResource(R.string.organize_screen_title), backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) + GlassScreenScaffold( + backdrop = backdrop, + contentBottomPadding = 16.dp, + header = { headerBackdrop -> + // Fade only β€” the header is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = stringResource(R.string.organize_screen_title), + backdrop = headerBackdrop, + onBack = onBack, + modifier = Modifier.graphicsLayer { alpha = topBarAlpha } + ) } - + ) { contentPadding -> Column( Modifier - .fillMaxWidth() - .weight(1f) + .fillMaxSize() + .padding(contentPadding) .graphicsLayer { alpha = contentAlpha translationY = contentOffsetY * density.density diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt new file mode 100644 index 0000000..9cc3139 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt @@ -0,0 +1,803 @@ +package com.chethan616.clearpdf.ui.screen + +import android.graphics.Bitmap +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.CircularProgressIndicator +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.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt +import com.chethan616.clearpdf.ui.viewmodel.FindMatch +import com.chethan616.clearpdf.ui.viewmodel.OcrTextBlock +import com.chethan616.clearpdf.ui.viewmodel.OcrTextRange +import kotlin.math.max +import kotlin.math.min + +/** + * A single page inside the continuous (Adobe-style) vertical viewer. + * + * Zoom and pan are owned by the parent container (a [graphicsLayer] wrapping the whole + * page column), so all pointer coordinates arrive already in this page's local, unscaled + * space. Because the rendered bitmap fills the item width, local space == content space + * and no manual zoom/pan projection is needed here β€” only tool gestures live on the page. + */ +@Composable +internal fun PdfContinuousPage( + page: Int, + bitmap: Bitmap?, + marks: MutableList, + ocrBlocks: List, + selectedOcrIds: Set, + selectedOcrRanges: List, + findMatches: List, + currentMatchIndex: Int, + showFindBar: Boolean, + activeTool: PdfEditTool, + currentColor: Color, + currentStrokeWidth: Float, + activeImageId: Long?, + pageCanvasSizes: SnapshotStateMap, + pageBitmapSizes: SnapshotStateMap, + onInteraction: () -> Unit, + /** A stroke/shape just landed on this page. Feeds the viewer's undo history. */ + onMarkAdded: () -> Unit = {}, + onToggleControls: () -> Unit, + onShowControls: () -> Unit, + onActiveToolChanged: (PdfEditTool) -> Unit, + onActiveImageIdChanged: (Long?) -> Unit, + onClearOcrSelection: () -> Unit, + onSelectOcrRange: (List) -> Unit, + onPlaceText: (Offset) -> Unit, + onPlaceNote: (Offset) -> Unit, + onEditAnnotation: (Long) -> Unit, + onEditShape: (Int) -> Unit = {}, + // Generic markup selection (shapes / text / notes) for move + resize. + selectedMarkupIndex: Int = -1, + onSelectMarkup: (Int) -> Unit = {}, + onDeleteMarkup: (Int) -> Unit = {}, + onCopySelection: () -> Unit = {}, + onHighlightSelection: () -> Unit = {} +) { + var draftPoints by remember(page, activeTool) { mutableStateOf>(emptyList()) } + var draftRectStart by remember(page, activeTool) { mutableStateOf(null) } + var draftRectEnd by remember(page, activeTool) { mutableStateOf(null) } + var selDragStart by remember(page, activeTool) { mutableStateOf(null) } + var selDragEnd by remember(page, activeTool) { mutableStateOf(null) } + val selectionHandleDiameterPx = with(LocalDensity.current) { 32.dp.toPx() } + val selectionHandleHitRadiusPx = with(LocalDensity.current) { 30.dp.toPx() } + + // Page layout (rebuilt on the Pdf_Tools model): the image is drawn at its TRUE + // aspect via ContentScale.FillWidth, so the box height follows the bitmap. There + // is no forced-aspect placeholder jump, and single / landscape pages lay out + // correctly. Every overlay uses matchParentSize() so its coordinate frame is + // exactly the image frame (0,0 β†’ box size). + Box( + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .then(if (bitmap == null) Modifier.aspectRatio(1f / 1.414f) else Modifier) + .background(Color(0xFF15181E)) + .onSizeChanged { sz -> + pageCanvasSizes[page] = Size(sz.width.toFloat(), sz.height.toFloat()) + if (bitmap != null) pageBitmapSizes[page] = Size(bitmap.width.toFloat(), bitmap.height.toFloat()) + } + ) { + if (bitmap == null) { + Box(Modifier.matchParentSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color(0xFF1976D2), strokeWidth = 2.dp) + } + return@Box + } + + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + + // The image fills the box width and the box height follows it, so the content + // frame is the full box. + Canvas(Modifier.matchParentSize()) { + val frame = Rect(0f, 0f, size.width, size.height) + + selectedOcrRanges.forEach { range -> + ocrBlocks.firstOrNull { it.id == range.blockId }?.let { b -> + val r = expandedTextHighlightRect(ocrTextRangeToRect(b, range, frame), verticalScale = 1.35f) + // Match the reference's soft cyan fill: no border, no font changes, and + // enough vertical air for ascenders/descenders without covering other lines. + val radius = (r.height * 0.14f).coerceIn(2f, 5f) + drawRoundRect(Color(0xFF9ADBF0).copy(0.82f), r.topLeft, r.size, CornerRadius(radius, radius)) + } + } + + marks.forEach { markup -> + when (markup) { + is PdfMarkup.StrokeMarkup -> if (markup.points.size > 1) { + drawPath(smoothPath(markup.points), markup.color.copy(markup.alpha), + style = Stroke(markup.width, cap = StrokeCap.Round, join = StrokeJoin.Round)) + } + is PdfMarkup.RectMarkup -> { + val r = Rect(min(markup.start.x, markup.end.x), min(markup.start.y, markup.end.y), max(markup.start.x, markup.end.x), max(markup.start.y, markup.end.y)) + if (markup.filled) drawRect(markup.color.copy(markup.alpha), r.topLeft, r.size) + else drawRect(markup.color.copy(markup.alpha), r.topLeft, r.size, style = Stroke(3f)) + } + is PdfMarkup.OvalMarkup -> { + val r = Rect(min(markup.start.x, markup.end.x), min(markup.start.y, markup.end.y), max(markup.start.x, markup.end.x), max(markup.start.y, markup.end.y)) + if (markup.filled) drawOval(markup.color.copy(markup.alpha), r.topLeft, r.size) + else drawOval(markup.color.copy(markup.alpha), r.topLeft, r.size, style = Stroke(3f)) + } + is PdfMarkup.LineMarkup -> + if (markup.arrowHead) drawArrow(markup.start, markup.end, markup.color.copy(markup.alpha), markup.width) + else drawLine(markup.color.copy(markup.alpha), markup.start, markup.end, markup.width) + is PdfMarkup.TextBlockHighlightMarkup -> ocrBlocks.firstOrNull { it.id == markup.blockId }?.let { b -> + val range = OcrTextRange(markup.blockId, markup.start, markup.end) + val r = expandedTextHighlightRect(ocrTextRangeToRect(b, range, frame)) + drawRoundRect(markup.color.copy(markup.alpha), r.topLeft, r.size, CornerRadius((r.height * 0.14f).coerceIn(2f, 5f), (r.height * 0.14f).coerceIn(2f, 5f))) + } + is PdfMarkup.TextBlockLineMarkup -> ocrBlocks.firstOrNull { it.id == markup.blockId }?.let { b -> + val r = ocrTextRangeToRect(b, OcrTextRange(markup.blockId, markup.start, markup.end), frame) + val y = markup.textMarkupLineY(r) ?: return@let + drawLine( + color = markup.color.copy(markup.alpha), + start = Offset(r.left, y), + end = Offset(r.right, y), + strokeWidth = markup.width.coerceIn(2f, 4f), + cap = StrokeCap.Round + ) + } + is PdfMarkup.ImageMarkup -> { + val r = Rect(min(markup.start.x, markup.end.x), min(markup.start.y, markup.end.y), max(markup.start.x, markup.end.x), max(markup.start.y, markup.end.y)) + if (!markup.bitmap.isRecycled && markup.bitmap.width > 0) runCatching { + drawImage( + image = markup.bitmap.asImageBitmap(), + srcOffset = androidx.compose.ui.unit.IntOffset.Zero, + srcSize = androidx.compose.ui.unit.IntSize(markup.bitmap.width, markup.bitmap.height), + dstOffset = androidx.compose.ui.unit.IntOffset(r.left.toInt(), r.top.toInt()), + dstSize = androidx.compose.ui.unit.IntSize(r.width.toInt().coerceAtLeast(1), r.height.toInt().coerceAtLeast(1)) + ) + } + if (activeTool == PdfEditTool.Image && markup.id == activeImageId) { + val accent = Color(0xFF0A84FF) + // Rounded selection frame. + drawRoundRect(accent, r.topLeft, r.size, CornerRadius(10f, 10f), style = Stroke(2.5f)) + // Passive corner dots (visual anchors). + listOf(r.topLeft, Offset(r.right, r.top), Offset(r.left, r.bottom)).forEach { c -> + drawCircle(Color.White, 8f, c) + drawCircle(accent, 8f, c, style = Stroke(2f)) + } + // Prominent bottom-right RESIZE handle with a diagonal glyph. + val br = Offset(r.right, r.bottom) + drawCircle(Color.White, 22f, br) + drawCircle(accent, 22f, br, style = Stroke(3f)) + drawLine(accent, Offset(br.x - 7f, br.y + 1f), Offset(br.x + 1f, br.y - 7f), 3f) + drawLine(accent, Offset(br.x - 1f, br.y + 7f), Offset(br.x + 7f, br.y - 1f), 3f) + } + } + is PdfMarkup.TextBoxMarkup -> { + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = markup.color.toArgb(); textSize = markup.fontSize + } + val linesT = if (markup.text.isEmpty()) listOf("") else markup.text.split("\n") + drawIntoCanvas { c -> + var yy = markup.position.y + markup.fontSize + linesT.forEach { ln -> c.nativeCanvas.drawText(ln, markup.position.x, yy, paint); yy += markup.fontSize * 1.2f } + } + if (markup.text.isEmpty()) drawRect(Color(0xFF1976D2).copy(0.5f), + Offset(markup.position.x - 4f, markup.position.y - 4f), Size(markup.fontSize * 5f, markup.fontSize * 1.4f), style = Stroke(2f)) + } + is PdfMarkup.NoteMarkup -> { + val sz = 30f; val tl = markup.anchor + drawRoundRect(markup.color, tl, Size(sz, sz), CornerRadius(6f, 6f)) + val fold = Path().apply { moveTo(tl.x + sz * 0.62f, tl.y); lineTo(tl.x + sz, tl.y + sz * 0.38f); lineTo(tl.x + sz * 0.62f, tl.y + sz * 0.38f); close() } + drawPath(fold, Color.White.copy(0.55f)) + val lc = Color.White.copy(0.75f) + drawLine(lc, Offset(tl.x + 6f, tl.y + sz * 0.56f), Offset(tl.x + sz - 6f, tl.y + sz * 0.56f), 2f) + drawLine(lc, Offset(tl.x + 6f, tl.y + sz * 0.74f), Offset(tl.x + sz - 9f, tl.y + sz * 0.74f), 2f) + } + } + } + + // Selection frame + handles for the selected shape / text / note. + if (activeTool == PdfEditTool.None) { + marks.getOrNull(selectedMarkupIndex)?.takeIf { it.isTransformable() }?.let { selM -> + selM.movableBounds()?.let { b -> + val accent = Color(0xFF0A84FF) + val fr = Rect(b.left - 6f, b.top - 6f, b.right + 6f, b.bottom + 6f) + drawRoundRect(accent, fr.topLeft, fr.size, CornerRadius(10f, 10f), style = Stroke(2.5f)) + // Passive anchor dots. + listOf(fr.topLeft, Offset(fr.right, fr.top), Offset(fr.left, fr.bottom)).forEach { c -> + drawCircle(Color.White, 7f, c); drawCircle(accent, 7f, c, style = Stroke(2f)) + } + // Bottom-right resize handle (hidden for fixed-size notes). + if (selM.isResizable()) { + val br = Offset(fr.right, fr.bottom) + drawCircle(Color.White, 20f, br); drawCircle(accent, 20f, br, style = Stroke(3f)) + drawLine(accent, Offset(br.x - 6f, br.y + 1f), Offset(br.x + 1f, br.y - 6f), 3f) + drawLine(accent, Offset(br.x - 1f, br.y + 6f), Offset(br.x + 6f, br.y - 1f), 3f) + } + } + } + + // OCR markups are anchored to extracted text rather than freely movable + // geometry, so they get their own subtle focus ring instead of the generic + // resize frame. The precise range remains visible and the action bubble below + // supplies the delete affordance. + marks.getOrNull(selectedMarkupIndex) + ?.takeIf { it is PdfMarkup.TextBlockHighlightMarkup || it is PdfMarkup.TextBlockLineMarkup } + ?.textMarkupRangeRect(ocrBlocks, frame) + ?.let { r -> + val focus = Color(0xFF0A84FF).copy(0.9f) + val bounds = if (marks[selectedMarkupIndex] is PdfMarkup.TextBlockLineMarkup) { + val y = marks[selectedMarkupIndex].textMarkupLineY(r) ?: r.bottom + Rect(r.left - 5f, y - 5f, r.right + 5f, y + 5f) + } else { + r.inflate(3f) + } + drawRoundRect( + focus, + bounds.topLeft, + bounds.size, + CornerRadius(6f, 6f), + style = Stroke(2f) + ) + } + } + + if (activeTool == PdfEditTool.SelectText) { + ocrSelectionHandleAnchors(ocrBlocks, selectedOcrRanges, frame)?.let { (start, end) -> + drawTextSelectionHandle(start, selectionHandleDiameterPx) + drawTextSelectionHandle(end, selectionHandleDiameterPx) + } + } + + // In-progress drafts + if (draftPoints.size > 1) { + val isHl = activeTool == PdfEditTool.Highlight + drawPath(smoothPath(draftPoints), currentColor.copy(if (isHl) 0.32f else 0.95f), + style = Stroke(if (isHl) currentStrokeWidth * 3.5f else currentStrokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round)) + } + if (draftRectStart != null && draftRectEnd != null) { + val s = draftRectStart!!; val e = draftRectEnd!! + val pr = Rect(min(s.x, e.x), min(s.y, e.y), max(s.x, e.x), max(s.y, e.y)) + when (activeTool) { + PdfEditTool.Rect -> drawRect(Color(0xFF42A5F5), pr.topLeft, pr.size, style = Stroke(3f)) + PdfEditTool.Ellipse -> drawOval(Color(0xFF26A69A), pr.topLeft, pr.size, style = Stroke(3f)) + PdfEditTool.Line -> drawLine(Color(0xFF66BB6A), s, e, 4f) + PdfEditTool.Arrow -> drawArrow(s, e, Color(0xFFEF5350), 4f) + else -> Unit + } + } + if (showFindBar && findMatches.isNotEmpty()) { + val activeMatch = findMatches.getOrNull(currentMatchIndex) + findMatches.filter { it.pageIndex == page }.forEach { match -> + // Highlight the exact matched WORD (normalized rect), not the whole line. + val r = Rect( + frame.left + match.left * frame.width, + frame.top + match.top * frame.height, + frame.left + match.right * frame.width, + frame.top + match.bottom * frame.height + ) + // The rect carries the word's EXACT bounds (per-glyph width Γ— glyph box height). + // Extend it a touch VERTICALLY so ascenders (the dot on "i") and descenders (the + // tail on "p"/"g") fall INSIDE the selection β€” the OCR/text box hugs the x-height, + // so without this those strokes poke out above/below the fill. A hair of horizontal + // padding keeps it snug across font sizes. + val padX = (r.height * 0.05f).coerceIn(0.75f, 3f) + val padTop = r.height * 0.13f + val padBottom = r.height * 0.11f + val hl = Rect(r.left - padX, r.top - padTop, r.right + padX, r.bottom + padBottom) + val cr = (hl.height * 0.14f).coerceIn(2f, 5f) + // A TRUE text-selection overlay: one uniform blue fill covering the whole glyph + // area (drawn over the page, so the glyphs read through it as a darker shape β€” + // Google-Docs / Acrobat style), not a faint tint sitting behind the ink. + if (match == activeMatch) { + val base = Color(0xFF3B82F6) + drawRoundRect(base.copy(0.52f), hl.topLeft, hl.size, CornerRadius(cr, cr)) + // Focused-result border (unchanged style) so the current hit stands out. + drawRoundRect(base.copy(0.9f), hl.topLeft, hl.size, CornerRadius(cr, cr), style = Stroke(1.25f)) + } else { + // Secondary results: same uniform coverage, lower emphasis, no border. + drawRoundRect(Color(0xFF60A5FA).copy(0.42f), hl.topLeft, hl.size, CornerRadius(cr, cr)) + } + } + } + } + + // ── Tool gesture layers (local coordinates == content coordinates) ────── + val drawingToolActive = activeTool in setOf( + PdfEditTool.Draw, PdfEditTool.Highlight, PdfEditTool.Rect, PdfEditTool.Ellipse, PdfEditTool.Line, PdfEditTool.Arrow + ) + + // Reading mode: a plain tap on a placed markup selects it. Images jump to their + // dedicated toolbar (replace/recolour/delete); shapes/text/notes get a selection + // frame with move + resize handles and a small Edit/Delete bar. A tap that misses + // every markup deselects and falls through to the container (chrome toggle / zoom). + if (activeTool == PdfEditTool.None) { + Box(Modifier.matchParentSize().pointerInput(page, marks.size) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + // If a markup is already selected, let its transform layer handle touches + // inside its frame (don't steal them here). + val sel = marks.getOrNull(selectedMarkupIndex)?.takeIf { it.isTransformable() } + val selBounds = sel?.movableBounds() + if (selBounds != null && selBounds.inflate(30f).contains(down.position)) return@awaitEachGesture + + val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) + val idx = marks.indexOfLast { it.hitTest(down.position, ocrBlocks, frame) } + if (idx >= 0) { + val hit = marks[idx] + val up = waitForUpOrCancellation() + if (up != null) { + up.consume() + when (hit) { + is PdfMarkup.ImageMarkup -> { + onSelectMarkup(-1) + onActiveImageIdChanged(hit.id) + onActiveToolChanged(PdfEditTool.Image) + } + is PdfMarkup.TextBoxMarkup, + is PdfMarkup.NoteMarkup, + is PdfMarkup.RectMarkup, + is PdfMarkup.OvalMarkup, + is PdfMarkup.LineMarkup, + is PdfMarkup.StrokeMarkup, + is PdfMarkup.TextBlockHighlightMarkup, + is PdfMarkup.TextBlockLineMarkup -> onSelectMarkup(idx) + else -> Unit + } + onShowControls() + onInteraction() + } + } else { + // Missed everything β†’ clear any selection (tap propagates to container). + if (selectedMarkupIndex >= 0) onSelectMarkup(-1) + } + } + }) + } + + // ── Transform layer: move + resize the selected shape / text / note ────── + val selForXf = marks.getOrNull(selectedMarkupIndex)?.takeIf { activeTool == PdfEditTool.None && it.isTransformable() } + val selXfBounds = selForXf?.movableBounds() + if (selForXf != null && selXfBounds != null) { + val density2 = LocalDensity.current + val pad = 30f + val boxL = selXfBounds.left - pad + val boxT = selXfBounds.top - pad + val boxW = selXfBounds.width + pad * 2 + val boxH = selXfBounds.height + pad * 2 + Box( + Modifier + .offset { IntOffset(boxL.roundToInt(), boxT.roundToInt()) } + .size(with(density2) { boxW.toDp() }, with(density2) { boxH.toDp() }) + .pointerInput(page, selectedMarkupIndex) { + var mode = 0 // 1 = move, 2 = resize + detectDragGestures( + onDragStart = { local -> + val cur = marks.getOrNull(selectedMarkupIndex) + val bb = cur?.movableBounds() + // Convert the box-local touch back to page space. + val pPage = Offset(local.x + boxL, local.y + boxT) + mode = if (bb != null && cur.isResizable() && (pPage - bb.bottomRight).getDistance() <= 60f) 2 else 1 + onInteraction() + }, + onDrag = { ch, drag -> + if (mode == 0) return@detectDragGestures + ch.consume() + val cur = marks.getOrNull(selectedMarkupIndex) ?: return@detectDragGestures + val bb = cur.movableBounds() ?: return@detectDragGestures + marks[selectedMarkupIndex] = if (mode == 2) cur.resizedBy(drag, bb) else cur.translated(drag) + onInteraction() + }, + onDragEnd = { mode = 0 }, + onDragCancel = { mode = 0 } + ) + } + ) + } + + if (drawingToolActive) { + // NOTE: currentColor/currentStrokeWidth are part of the key so the gesture + // detector restarts and re-captures them whenever they change. Without this + // the onDragEnd closure keeps the color/width captured when the tool was first + // selected β€” so changing color mid-tool wouldn't apply until you switched tools. + Box(Modifier.matchParentSize().pointerInput(page, activeTool, currentColor, currentStrokeWidth) { + val freehand = activeTool == PdfEditTool.Draw || activeTool == PdfEditTool.Highlight + detectDragGestures( + onDragStart = { p -> onInteraction(); if (freehand) draftPoints = listOf(p) else { draftRectStart = p; draftRectEnd = p } }, + onDrag = { ch, _ -> ch.consume(); if (freehand) draftPoints = draftPoints + ch.position else draftRectEnd = ch.position }, + onDragCancel = { draftPoints = emptyList(); draftRectStart = null; draftRectEnd = null }, + onDragEnd = { + val before = marks.size + when (activeTool) { + PdfEditTool.Draw -> if (draftPoints.size > 1) marks.add(PdfMarkup.StrokeMarkup(draftPoints, currentColor, currentStrokeWidth, 0.95f)) + PdfEditTool.Highlight -> if (draftPoints.size > 1) marks.add(PdfMarkup.StrokeMarkup(draftPoints, currentColor, currentStrokeWidth * 3.5f, 0.32f)) + PdfEditTool.Rect -> draftRectStart?.let { s -> draftRectEnd?.let { e -> marks.add(PdfMarkup.RectMarkup(s, e, currentColor, 1f, false)) } } + PdfEditTool.Ellipse -> draftRectStart?.let { s -> draftRectEnd?.let { e -> marks.add(PdfMarkup.OvalMarkup(s, e, currentColor, 1f, false)) } } + PdfEditTool.Line -> draftRectStart?.let { s -> draftRectEnd?.let { e -> marks.add(PdfMarkup.LineMarkup(s, e, currentColor, currentStrokeWidth, 1f, false)) } } + PdfEditTool.Arrow -> draftRectStart?.let { s -> draftRectEnd?.let { e -> marks.add(PdfMarkup.LineMarkup(s, e, currentColor, currentStrokeWidth, 1f, true)) } } + else -> Unit + } + // Every branch above is conditional (a tap with <2 points adds nothing), so + // compare sizes rather than assuming the drag produced a mark. + if (marks.size > before) onMarkAdded() + draftPoints = emptyList(); draftRectStart = null; draftRectEnd = null + } + ) + }) + } + + if (activeTool == PdfEditTool.Eraser) { + Box(Modifier.matchParentSize().pointerInput(page) { + detectTapGestures { + p -> + val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) + val idx = marks.indexOfLast { it.hitTest(p, ocrBlocks, frame) } + if (idx >= 0) marks.removeAt(idx) + onInteraction() + } + }.pointerInput(page) { + detectDragGestures(onDrag = { ch, _ -> + ch.consume() + val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) + val idx = marks.indexOfLast { it.hitTest(ch.position, ocrBlocks, frame) } + if (idx >= 0) marks.removeAt(idx) + onInteraction() + }) + }) + } + + if (activeTool == PdfEditTool.Text || activeTool == PdfEditTool.Note) { + Box(Modifier.matchParentSize().pointerInput(page, activeTool) { + detectTapGestures { p -> if (activeTool == PdfEditTool.Text) onPlaceText(p) else onPlaceNote(p); onInteraction() } + }) + } + + if (activeTool == PdfEditTool.Image && activeImageId != null) { + Box(Modifier.matchParentSize() + .pointerInput(page, activeImageId) { + detectTapGestures { p -> + val hit = marks.lastOrNull { it is PdfMarkup.ImageMarkup && it.hitTest(p) } as? PdfMarkup.ImageMarkup + if (hit != null) { onActiveImageIdChanged(hit.id); onShowControls() } + else { onActiveImageIdChanged(null); onActiveToolChanged(PdfEditTool.None); onToggleControls() } + } + } + .pointerInput(page, activeImageId) { + var resizing = false + detectDragGestures( + onDragStart = { p -> + val idx = marks.indexOfLast { it is PdfMarkup.ImageMarkup && it.id == activeImageId } + val img = marks.getOrNull(idx) as? PdfMarkup.ImageMarkup + // Generous grab radius around the bottom-right handle (Apple-style + // touch target much larger than the visual handle). + resizing = img != null && (p - img.end).getDistance() <= 64f; onInteraction() + }, + onDrag = { ch, drag -> + ch.consume() + val idx = marks.indexOfLast { it is PdfMarkup.ImageMarkup && it.id == activeImageId } + val img = marks.getOrNull(idx) as? PdfMarkup.ImageMarkup ?: return@detectDragGestures + // Clamp to the page bounds so the image can never be dragged past the + // page edge (where it would be clipped and hidden behind the next page). + val pw = size.width.toFloat(); val ph = size.height.toFloat() + marks[idx] = if (resizing) { + img.copy(end = Offset( + (img.end.x + drag.x).coerceIn(img.start.x + 24f, pw), + (img.end.y + drag.y).coerceIn(img.start.y + 24f, ph) + )) + } else { + val iw = img.end.x - img.start.x; val ih = img.end.y - img.start.y + val nx = (img.start.x + drag.x).coerceIn(0f, (pw - iw).coerceAtLeast(0f)) + val ny = (img.start.y + drag.y).coerceIn(0f, (ph - ih).coerceAtLeast(0f)) + img.copy(start = Offset(nx, ny), end = Offset(nx + iw, ny + ih)) + } + onInteraction() + } + ) + } + ) + } + + if (activeTool == PdfEditTool.SelectText) { + Box(Modifier.matchParentSize() + .pointerInput(page, ocrBlocks) { + val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) + detectTapGestures( + // A tap or double-tap lands on one word. Long-press follows the platform + // text-selection convention: select that word and reveal the contextual + // Copy / Highlight actions above it. + onDoubleTap = { p -> hitTestOcrWord(ocrBlocks, p, frame)?.let { onSelectOcrRange(listOf(it)) }; onInteraction() }, + onLongPress = { p -> hitTestOcrWord(ocrBlocks, p, frame)?.let { onSelectOcrRange(listOf(it)) }; onInteraction() }, + onTap = { p -> hitTestOcrWord(ocrBlocks, p, frame)?.let { onSelectOcrRange(listOf(it)) } ?: onClearOcrSelection(); onInteraction() } + ) + } + // Direct drag (no long-press wait): sweep a contiguous run of words in reading order + // and update the range live. The page stays in text-selection mode, while the parent + // viewer still receives two-finger pinch events for zoom. + .pointerInput(page, ocrBlocks, selectedOcrRanges, selectionHandleHitRadiusPx) { + fun updateRange() { + val s = selDragStart; val e = selDragEnd + if (s == null || e == null || ocrBlocks.isEmpty()) return + val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) + onSelectOcrRange(ocrWordRangesBetween(ocrBlocks, frame, s, e)) + } + + // Do not use detectDragGestures here: it commits to a one-finger drag before + // the second finger of a pinch arrives. That is why zooming used to leave a + // text selection behind. We wait for touch slop, then only consume while the + // gesture remains single-touch; a second finger cancels selection immediately + // and leaves the event stream available to the viewer's pinch detector. + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) + val handles = ocrSelectionHandleAnchors(ocrBlocks, selectedOcrRanges, frame) + val handleMode = when { + handles == null -> 0 + (down.position - handles.first).getDistance() <= selectionHandleHitRadiusPx -> 1 + (down.position - handles.second).getDistance() <= selectionHandleHitRadiusPx -> 2 + else -> 0 + } + val fixedHandlePoint = when (handleMode) { + 1 -> handles?.second + 2 -> handles?.first + else -> null + } + val pointerId = down.id + var selecting = false + var multiTouch = false + do { + val event = awaitPointerEvent() + val pressed = event.changes.count { it.pressed } + if (pressed > 1) { + if (!multiTouch) { + multiTouch = true + if (selecting) onClearOcrSelection() + } + selDragStart = null + selDragEnd = null + } else if (!multiTouch) { + val change = event.changes.firstOrNull { it.id == pointerId } + if (change != null) { + if (!selecting && (change.position - down.position).getDistance() > viewConfiguration.touchSlop) { + selecting = true + selDragStart = fixedHandlePoint ?: down.position + selDragEnd = change.position + updateRange() + onInteraction() + } else if (selecting) { + change.consume() + selDragStart = fixedHandlePoint ?: down.position + selDragEnd = change.position + updateRange() + onInteraction() + } + } + } + } while (event.changes.any { it.pressed }) + selDragStart = null + selDragEnd = null + } + } + ) + } + + // ── Contextual selection bubble (Copy / Highlight), smart-positioned ────── + val csz = pageCanvasSizes[page] + if (activeTool == PdfEditTool.SelectText && selectedOcrIds.isNotEmpty() && csz != null && csz.width > 0f) { + val frame = Rect(0f, 0f, csz.width, csz.height) + val selRects = selectedOcrRanges.mapNotNull { range -> + ocrBlocks.firstOrNull { it.id == range.blockId }?.let { ocrTextRangeToRect(it, range, frame) } + }.ifEmpty { + selectedOcrIds.mapNotNull { id -> ocrBlocks.firstOrNull { it.id == id }?.let { ocrBlockToRect(it, frame) } } + } + if (selRects.isNotEmpty()) { + val density = LocalDensity.current + val selLeft = selRects.minOf { it.left } + val selTop = selRects.minOf { it.top } + val selRight = selRects.maxOf { it.right } + val selBottom = selRects.maxOf { it.bottom } + // Is any selected word already carrying a highlight? If so the pill gains a Remove + // action β€” this is the fix for "tap a highlight again and there's no delete, only + // copy/highlight". The selection and the highlight live in two different models (OCR + // selection vs. the page's markup list), so we cross-reference by block id. + val selectionHasHighlight = selectedOcrIds.any { id -> + marks.any { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == id } + } + val gapPx = with(density) { 8.dp.toPx() } + val bubbleHpx = with(density) { 44.dp.toPx() } + val bubbleWpx = with(density) { (if (selectionHasHighlight) 226.dp else 150.dp).toPx() } + // Flip below the selection when it's too close to the page top. + val placeBelow = selTop < bubbleHpx + gapPx + val by = (if (placeBelow) selBottom + gapPx else selTop - bubbleHpx - gapPx).coerceIn(0f, (csz.height - bubbleHpx).coerceAtLeast(0f)) + val bx = ((selLeft + selRight) / 2f - bubbleWpx / 2f).coerceIn(0f, (csz.width - bubbleWpx).coerceAtLeast(0f)) + + Row( + Modifier + .offset { IntOffset(bx.roundToInt(), by.roundToInt()) } + .clip(RoundedCornerShape(22.dp)) + .background(Color(0xFF1C1F26).copy(0.97f)) + .border(1.dp, Color.White.copy(0.14f), RoundedCornerShape(22.dp)) + .padding(horizontal = 4.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + "Copy", + style = TextStyle(Color.White, 13.sp, FontWeight.SemiBold), + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable { onCopySelection(); onClearOcrSelection() } + .padding(horizontal = 16.dp, vertical = 9.dp) + ) + Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f))) + BasicText( + if (selectionHasHighlight) "Recolor" else "Highlight", + style = TextStyle(Color(0xFFFFCC33), 13.sp, FontWeight.SemiBold), + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable { onHighlightSelection() } + .padding(horizontal = 16.dp, vertical = 9.dp) + ) + if (selectionHasHighlight) { + Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f))) + BasicText( + "Delete", + style = TextStyle(Color(0xFFFF6B6B), 13.sp, FontWeight.SemiBold), + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable { + // Drop every highlight sitting on a selected word, then clear the + // selection so the pill dismisses. + marks.removeAll { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId in selectedOcrIds } + onClearOcrSelection() + } + .padding(horizontal = 16.dp, vertical = 9.dp) + ) + } + } + } + } + + // ── Contextual Edit / Delete bar for the selected shape / text / note ────── + if (activeTool == PdfEditTool.None && csz != null && csz.width > 0f) { + marks.getOrNull(selectedMarkupIndex)?.takeIf { it.isTransformable() }?.let { selM -> + selM.movableBounds()?.let { b -> + val density = LocalDensity.current + val gapPx = with(density) { 12.dp.toPx() } + val barHpx = with(density) { 44.dp.toPx() } + val barWpx = with(density) { 132.dp.toPx() } + val placeBelow = b.top < barHpx + gapPx + val by = (if (placeBelow) b.bottom + gapPx else b.top - barHpx - gapPx) + .coerceIn(0f, (csz.height - barHpx).coerceAtLeast(0f)) + val bx = ((b.left + b.right) / 2f - barWpx / 2f) + .coerceIn(0f, (csz.width - barWpx).coerceAtLeast(0f)) + + Row( + Modifier + .offset { IntOffset(bx.roundToInt(), by.roundToInt()) } + .clip(RoundedCornerShape(22.dp)) + .background(Color(0xFF1C1F26).copy(0.97f)) + .border(1.dp, Color.White.copy(0.14f), RoundedCornerShape(22.dp)) + .padding(horizontal = 4.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + "Edit", + style = TextStyle(Color.White, 13.sp, FontWeight.SemiBold), + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable { + when (selM) { + is PdfMarkup.TextBoxMarkup -> onEditAnnotation(selM.id) + is PdfMarkup.NoteMarkup -> onEditAnnotation(selM.id) + else -> onEditShape(selectedMarkupIndex) + } + } + .padding(horizontal = 16.dp, vertical = 9.dp) + ) + Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f))) + BasicText( + "Delete", + style = TextStyle(Color(0xFFFF6B6B), 13.sp, FontWeight.SemiBold), + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable { onDeleteMarkup(selectedMarkupIndex) } + .padding(horizontal = 16.dp, vertical = 9.dp) + ) + } + } + } + + // Text highlights, underlines, and strike-throughs are precise OCR ranges, not + // transformable shapes. When one is tapped, expose the same clear destructive action + // used by the other professional markup tools. + marks.getOrNull(selectedMarkupIndex) + ?.takeIf { it is PdfMarkup.TextBlockHighlightMarkup || it is PdfMarkup.TextBlockLineMarkup } + ?.textMarkupRangeRect(ocrBlocks, Rect(0f, 0f, csz.width, csz.height)) + ?.let { rangeRect -> + val selected = marks[selectedMarkupIndex] + val anchorRect = if (selected is PdfMarkup.TextBlockLineMarkup) { + val y = selected.textMarkupLineY(rangeRect) ?: rangeRect.bottom + Rect(rangeRect.left, y - 4f, rangeRect.right, y + 4f) + } else rangeRect + val density = LocalDensity.current + val gapPx = with(density) { 10.dp.toPx() } + val barHpx = with(density) { 44.dp.toPx() } + val barWpx = with(density) { 96.dp.toPx() } + val placeBelow = anchorRect.top < barHpx + gapPx + val by = (if (placeBelow) anchorRect.bottom + gapPx else anchorRect.top - barHpx - gapPx) + .coerceIn(0f, (csz.height - barHpx).coerceAtLeast(0f)) + val bx = ((anchorRect.left + anchorRect.right) / 2f - barWpx / 2f) + .coerceIn(0f, (csz.width - barWpx).coerceAtLeast(0f)) + + Row( + Modifier + .offset { IntOffset(bx.roundToInt(), by.roundToInt()) } + .clip(RoundedCornerShape(22.dp)) + .background(Color(0xFF1C1F26).copy(0.97f)) + .border(1.dp, Color.White.copy(0.14f), RoundedCornerShape(22.dp)) + .padding(horizontal = 4.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicText( + "Delete", + style = TextStyle(Color(0xFFFF6B6B), 13.sp, FontWeight.SemiBold), + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable { onDeleteMarkup(selectedMarkupIndex) } + .padding(horizontal = 16.dp, vertical = 9.dp) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfPageScrubber.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfPageScrubber.kt new file mode 100644 index 0000000..5e9eb97 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfPageScrubber.kt @@ -0,0 +1,259 @@ +package com.chethan616.clearpdf.ui.screen + +import android.graphics.Bitmap +import android.view.HapticFeedbackConstants +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.ViewerChromeGlass +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.UISensor +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlin.math.roundToInt + +/** + * Adobe Acrobat-style vertical scrub bar pinned to the right margin. + * + * A thin, unobtrusive track that thickens while dragging. Dragging snaps to page + * steps with a haptic tick per boundary and surfaces a floating "Page X of Y" + * bubble with a live thumbnail preview. Visibility is owned by the parent (via + * [AnimatedVisibility]); this composable only animates its own drag affordances. + */ +private val TRACK_HEIGHT = 208.dp + +@Composable +internal fun PageScrubber( + currentPage: Int, + pageCount: Int, + pageBitmaps: List, + backdrop: LayerBackdrop, + uiSensor: UISensor, + onPageChange: (Int) -> Unit, + onPageScrubbing: (Int) -> Unit, + /** + * Reported on every rail grab/release. The parent fades the whole scrubber down when the list + * is at rest, and dragging the rail does not scroll the list (that only happens on release), so + * without this the preview bubble fades to 40% opacity in the middle of a drag. + */ + onDraggingChange: (Boolean) -> Unit = {}, + isScrolling: Boolean = false, + modifier: Modifier = Modifier +) { + val isDark = LocalIsDarkMode.current + val view = LocalView.current + val accent = Color(0xFF0A84FF) + // Idle thumb: soft light blue β€” visible on both themes, not harsh like pure white. + val idleThumb = Color(0xFF8FBFFF) + + var isDragging by remember { mutableStateOf(false) } + var dragPage by remember { mutableIntStateOf(currentPage) } + val lastSpan = (pageCount - 1).coerceAtLeast(1) + + // Follow the pager when not actively scrubbing. + LaunchedEffect(currentPage) { if (!isDragging) dragPage = currentPage } + + // Haptic tick each time the resolved page changes during a drag. + LaunchedEffect(dragPage, isDragging) { + if (isDragging) { + runCatching { view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) } + onPageScrubbing(dragPage) + } + } + + val fraction by animateFloatAsState( + targetValue = (dragPage.toFloat() / lastSpan).coerceIn(0f, 1f), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy), + label = "scrubFraction" + ) + val trackWidth by animateDpAsState( + targetValue = if (isDragging) 8.dp else 4.dp, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "trackWidth" + ) + val thumbHeight by animateDpAsState( + targetValue = if (isDragging) 40.dp else 30.dp, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "thumbHeight" + ) + + Box(modifier = modifier) { + // ── The scrub track (drag target) ─────────────────────────────────── + Box( + Modifier + // Pin the rail to the right edge so it doesn't jump left when the wider preview + // bubble appears (which grows the parent Box's width during a drag). + .align(Alignment.CenterEnd) + .height(TRACK_HEIGHT) + .width(28.dp) + .pointerInput(pageCount) { + detectTapGestures { offset -> + val target = ((offset.y / size.height) * lastSpan).roundToInt().coerceIn(0, lastSpan) + dragPage = target + runCatching { view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) } + onPageChange(target) + } + } + .pointerInput(pageCount) { + detectDragGestures( + onDragStart = { start -> + isDragging = true + onDraggingChange(true) + dragPage = ((start.y / size.height) * lastSpan).roundToInt().coerceIn(0, lastSpan) + }, + onDrag = { change, _ -> + change.consume() + dragPage = ((change.position.y / size.height) * lastSpan).roundToInt().coerceIn(0, lastSpan) + }, + onDragEnd = { isDragging = false; onDraggingChange(false); onPageChange(dragPage) }, + onDragCancel = { isDragging = false; onDraggingChange(false); onPageChange(dragPage) } + ) + }, + contentAlignment = Alignment.Center + ) { + // Background rail + Box( + Modifier + .width(trackWidth) + .height(TRACK_HEIGHT) + .clip(RoundedCornerShape(50)) + .background(if (isDark) Color.White.copy(0.14f) else Color.Black.copy(0.10f)), + contentAlignment = Alignment.TopCenter + ) { + // Draggable thumb + Box( + Modifier + .padding(top = ((TRACK_HEIGHT - thumbHeight) * fraction).coerceAtLeast(0.dp)) + .width(if (isDragging) 8.dp else 4.dp) + .height(thumbHeight) + .clip(RoundedCornerShape(50)) + .background(if (isDragging) accent else idleThumb) + ) + } + } + + // ── Floating page thumbnail (a compact scroll navigator, NOT a second document) ────── + // A clean miniature page card that follows the scroll/drag position on the right. It's an + // overlay (no glass container behind it, no grey pill), has no gesture handler so it never + // blocks document scrolling, preserves the real page aspect ratio, and stays inside the + // track. Shown ONLY while dragging the rail (never on normal scroll/fling). + val screenW = LocalConfiguration.current.screenWidthDp + val thumbW = when { + screenW < 340 -> 0.dp // too little room β†’ rail only, hide the thumbnail + screenW < 400 -> 84.dp + else -> 104.dp + } + AnimatedVisibility( + visible = isDragging && thumbW > 0.dp, + enter = fadeIn(tween(140)) + scaleIn(initialScale = 0.92f, animationSpec = tween(160)), + exit = fadeOut(tween(200)) + scaleOut(targetScale = 0.94f), + modifier = Modifier.align(Alignment.CenterEnd) + ) { + // Keep the LAST GOOD bitmap + aspect. The scrubbed page's high-res bitmap renders + // asynchronously, so during a fast drag it's often momentarily null (or a low-res cache + // being upgraded). Rather than crossfade null↔bitmap or low-res↔high-res (which read as + // a half/ghost render that stutters), we swap the image IN PLACE and, when the target is + // not ready yet, keep showing the last good one β€” the preview simply "catches up". + var lastShown by remember { mutableStateOf(null) } + var lastAspect by remember { mutableFloatStateOf(1f / 1.414f) } + val live = pageBitmaps.getOrNull(dragPage)?.takeIf { !it.isRecycled && it.height > 0 } + if (live != null) { + lastShown = live + lastAspect = live.width.toFloat() / live.height.toFloat() + } + val shown = live ?: lastShown?.takeIf { !it.isRecycled } + // Follow the scroll position, clamped to the track slack so it's never clipped at the ends. + val yOffset = (TRACK_HEIGHT * fraction - TRACK_HEIGHT / 2f).coerceIn(-22.dp, 22.dp) + + Column( + modifier = Modifier + .offset(x = (-34).dp, y = yOffset) + .width(thumbW) + // No elevation shadow: an offset elevation shadow with clip=false bleeds past + // the scrubber's offscreen alpha layer and reads as a torn edge. A clean clipped + // card with a slightly stronger border gives the same lift without the artifact. + .clip(RoundedCornerShape(11.dp)) + .background(if (isDark) Color(0xFF2A2E37) else Color.White) + .border(1.dp, if (isDark) Color.White.copy(0.18f) else Color.Black.copy(0.14f), RoundedCornerShape(11.dp)) + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(lastAspect) // stable aspect β†’ no layout jolt between pages + .clip(RoundedCornerShape(6.dp)) + .background(if (isDark) Color(0xFF1B1E24) else Color(0xFFF2F3F5)), + contentAlignment = Alignment.Center + ) { + if (shown != null) { + Image( + bitmap = shown.asImageBitmap(), + contentDescription = stringResource(R.string.preview_page, dragPage + 1), + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } else { + // Nothing rendered yet at all β†’ a faint spinner (only on the very first preview). + CircularProgressIndicator(color = accent.copy(0.7f), strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + } + } + BasicText( + "${dragPage + 1} / $pageCount", + style = TextStyle(if (isDark) Color.White.copy(0.85f) else Color(0xFF333333), 10.sp, fontWeight = FontWeight.SemiBold) + ) + } + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt new file mode 100644 index 0000000..d421704 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt @@ -0,0 +1,129 @@ +package com.chethan616.clearpdf.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.CloseCrossIcon +import com.chethan616.clearpdf.ui.components.GlassSearchPill +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.utils.UISensor +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay + +/** [LiquidIconButton]'s own size, so these read as the top bar's circles moved down the screen. */ +private val FindBarButtonSize = 40.dp + +/** + * The find bar for both viewers. It is the same 36 dp [GlassSearchPill] Home and Tools use β€” one + * search shape across the app β€” with the nav circles moved *outside* the capsule so the pill stays a + * pure input rather than a slab with controls buried in it. Both the pill and the circles wear the + * viewer's chrome glass, so the bar is the top bar's material, just lower down. + * + * The adaptive chrome palette ([fg]/[fgSoft]/[surface]) still threads through: on a light page the + * viewer inverts its chrome, and the find bar has to follow or it goes unreadable. + */ +@Composable +internal fun PdfSearchBar( + query: String, + matchCount: Int, + currentMatchIndex: Int, + focusRequester: FocusRequester, + backdrop: LayerBackdrop, + uiSensor: UISensor, + // Adaptive chrome palette (dark ink on light pages, white on dark pages). + fg: Color, + fgSoft: Color, + surface: Color, + onQueryChange: (String) -> Unit, + onPrevMatch: () -> Unit, + onNextMatch: () -> Unit, + onClose: () -> Unit +) { + LaunchedEffect(Unit) { + delay(100) + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + GlassSearchPill( + query = query, + onQueryChange = onQueryChange, + hint = stringResource(R.string.viewer_find_hint), + backdrop = backdrop, + uiSensor = uiSensor, + modifier = Modifier.weight(1f), + focusRequester = focusRequester, + // The bar's own slide-up is the entrance; a spring-in on the pill would stack on it. + animateIn = false, + // Same material as the top bar's title pill, not Home's heavier app chrome. + viewerChrome = true, + surfaceColor = surface, + contentColor = fg, + hintColor = fgSoft, + // The match count belongs with the query it counts, not out beside the buttons. + trailing = { + if (matchCount > 0) { + BasicText( + "${currentMatchIndex + 1}/$matchCount", + style = TextStyle(fgSoft, 11.sp, FontWeight.SemiBold) + ) + } else if (query.isNotBlank()) { + BasicText( + stringResource(R.string.viewer_find_no_results), + style = TextStyle(Color(0xFFE53935), 11.sp, FontWeight.Medium) + ) + } + } + ) + + // 40 dp and `surface`, matching the top bar's circles exactly. `field` is only 6-10% alpha, + // which sets no value of its own and leaves the button refracting whatever is behind it β€” + // the same reason single-page chrome went invisible. + LiquidIconButton( + onClick = onPrevMatch, + backdrop = backdrop, + surfaceColor = surface, + modifier = Modifier.size(FindBarButtonSize) + ) { + Icon(Icons.Rounded.KeyboardArrowUp, stringResource(R.string.previous), Modifier.size(20.dp), fg) + } + LiquidIconButton( + onClick = onNextMatch, + backdrop = backdrop, + surfaceColor = surface, + modifier = Modifier.size(FindBarButtonSize) + ) { + Icon(Icons.Rounded.KeyboardArrowDown, stringResource(R.string.next), Modifier.size(20.dp), fg) + } + LiquidIconButton( + onClick = onClose, + backdrop = backdrop, + surfaceColor = Color(0xFFEF5350).copy(0.22f), + modifier = Modifier.size(FindBarButtonSize) + ) { + CloseCrossIcon(Modifier.size(13.dp), fg) + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfToImagesScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfToImagesScreen.kt new file mode 100644 index 0000000..1e03346 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfToImagesScreen.kt @@ -0,0 +1,244 @@ +package com.chethan616.clearpdf.ui.screen + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import android.graphics.BitmapFactory +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.PdfToImagesViewModel +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.pdfcore.raster.PdfRasterizer +import kotlinx.coroutines.delay + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PdfToImagesScreen( + backdrop: LayerBackdrop, + viewModel: PdfToImagesViewModel, + onBack: () -> Unit +) { + val state by viewModel.uiState.collectAsState() + val isDark = LocalIsDarkMode.current + val isLight = !isDark + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) + val accent = Color(0xFF00ACC1) + val uiSensor = rememberUISensor() + val context = LocalContext.current + val density = LocalDensity.current.density + + LaunchedEffect(state.resultMessage, state.errorMessage) { + if (!state.resultMessage.isNullOrBlank() || !state.errorMessage.isNullOrBlank()) { + delay(3200); viewModel.clearFeedback() + } + } + + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) viewModel.onSelectFile(context, uri) + } + + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { isVisible = true } + val topAlpha by animateFloatAsState(if (isVisible) 1f else 0f, tween(500, easing = FastOutSlowInEasing), label = "t") + val bodyAlpha by animateFloatAsState(if (isVisible) 1f else 0f, tween(600, 100, FastOutSlowInEasing), label = "b") + val bodyY by animateFloatAsState(if (isVisible) 0f else 24f, tween(600, 100, FastOutSlowInEasing), label = "by") + + GlassScreenScaffold( + backdrop = backdrop, + header = { headerBackdrop -> + // Fade only β€” the header is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = androidx.compose.ui.res.stringResource(R.string.tool_pdf_to_images), + backdrop = headerBackdrop, + onBack = onBack, + modifier = Modifier.graphicsLayer { alpha = topAlpha } + ) + } + ) { contentPadding -> + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(contentPadding) + .graphicsLayer { alpha = bodyAlpha; translationY = bodyY * density }, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Icon(Icons.Rounded.Image, null, Modifier.size(52.dp), accent) + BasicText(androidx.compose.ui.res.stringResource(R.string.tool_pdf_to_images), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(androidx.compose.ui.res.stringResource(R.string.tool_pdf_to_images_desc), style = TextStyle(sub, 13.sp, textAlign = TextAlign.Center)) + + LiquidButton(onClick = { filePicker.launch(arrayOf("application/pdf")) }, backdrop = backdrop, tint = accent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(androidx.compose.ui.res.stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + } + + if (state.sourceName.isNotEmpty()) { + Column(Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + BasicText(state.sourceName, style = TextStyle(text, 15.sp, fontWeight = FontWeight.SemiBold), maxLines = 1) + BasicText(androidx.compose.ui.res.stringResource(R.string.create_pages, state.pageCount), style = TextStyle(sub, 13.sp)) + } + + // Format selector + Column(Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + BasicText(androidx.compose.ui.res.stringResource(R.string.pdf_to_images_format), style = TextStyle(text, 14.sp, FontWeight.Medium)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + PdfRasterizer.ImageFormat.entries.forEach { fmt -> + val selected = state.format == fmt + LiquidButton( + onClick = { viewModel.onFormatChange(fmt) }, + backdrop = backdrop, + tint = if (selected) accent else Color.Transparent, + surfaceColor = if (selected) accent else Color.White.copy(0.08f), + modifier = Modifier.weight(1f) + ) { + BasicText(fmt.extension.uppercase(), style = TextStyle(if (selected) Color.White else text, 13.sp, FontWeight.Medium)) + } + } + } + } + + val runLabel = if (state.isProcessing) + androidx.compose.ui.res.stringResource(R.string.pdf_to_images_working, (state.progress * 100).toInt()) + else androidx.compose.ui.res.stringResource(R.string.pdf_to_images_convert) + + LiquidButton(onClick = { viewModel.run(context) }, backdrop = backdrop, tint = accent, isInteractive = !state.isProcessing, modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + if (state.isProcessing) CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) + else Icon(Icons.Rounded.Image, null, Modifier.size(18.dp), Color.White) + BasicText(runLabel, style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + } + + if (state.resultPages.isNotEmpty()) { + Column(Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + BasicText(androidx.compose.ui.res.stringResource(R.string.pdf_to_images_preview), style = TextStyle(text, 14.sp, FontWeight.Medium)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + state.resultPages.take(12).forEach { page -> + val thumb = remember(page.file.path) { + runCatching { + val opts = BitmapFactory.Options().apply { inSampleSize = 4 } + BitmapFactory.decodeFile(page.file.path, opts) + }.getOrNull() + } + Box( + Modifier.size(width = 64.dp, height = 84.dp).clip(RoundedCornerShape(8.dp)) + .border(1.dp, sub.copy(0.3f), RoundedCornerShape(8.dp)) + .background(if (isLight) Color(0xFFF5F5F5) else Color(0xFF333333)) + ) { + if (thumb != null) { + Image(thumb.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + } + Box( + Modifier.align(Alignment.BottomCenter).fillMaxWidth() + .background(Color.Black.copy(0.45f)).padding(vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + BasicText("${page.pageIndex + 1}", style = TextStyle(Color.White, 11.sp, FontWeight.Bold)) + } + } + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LiquidButton(onClick = { viewModel.saveToGallery(context) }, backdrop = backdrop, tint = Color(0xFF43A047), modifier = Modifier.weight(1f)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Save, null, Modifier.size(18.dp), Color.White) + BasicText(androidx.compose.ui.res.stringResource(R.string.pdf_to_images_save), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + } + } + LiquidButton(onClick = { viewModel.shareAll(context) }, backdrop = backdrop, surfaceColor = Color.White.copy(0.1f), modifier = Modifier.weight(1f)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Share, null, Modifier.size(18.dp), Color.White) + BasicText(androidx.compose.ui.res.stringResource(R.string.share), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + } + } + } + } + } + + state.errorMessage?.let { + Column(Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(16.dp)) { + BasicText(it, style = TextStyle(Color(0xFFD32F2F), 14.sp)) + } + } + state.resultMessage?.let { + Column(Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(16.dp)) { + BasicText(it, style = TextStyle(Color(0xFF388E3C), 14.sp)) + } + } + + Spacer(Modifier.height(60.dp)) + } + } +} + +@Composable +private fun stringResourceSafe(): String = androidx.compose.ui.res.stringResource(R.string.back) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt new file mode 100644 index 0000000..77ada9a --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt @@ -0,0 +1,803 @@ +package com.chethan616.clearpdf.ui.screen + +import android.net.Uri +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.zIndex +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Brush +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CropSquare +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.FormatListNumbered +import androidx.compose.material.icons.rounded.Gesture +import androidx.compose.material.icons.rounded.RadioButtonUnchecked +import androidx.compose.material.icons.rounded.Remove +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Slideshow +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material.icons.rounded.SwapVert +import androidx.compose.material.icons.rounded.Undo +import androidx.compose.material.icons.rounded.IosShare +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.Icon +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import kotlinx.coroutines.delay +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.CloseCrossIcon +import com.chethan616.clearpdf.ui.components.GlassMotion +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.ShareMorphButton +import com.chethan616.clearpdf.ui.components.carouselEdges +import com.chethan616.clearpdf.ui.components.viewerGlass +import com.chethan616.clearpdf.ui.utils.UISensor +import com.chethan616.clearpdf.utils.DocKind +import com.kyant.backdrop.backdrops.LayerBackdrop + +/** + * How long one face (selector or sub-toolbar) takes to fade out before the other fades in. The + * fade-out tweens use the same value, so the incoming face starts exactly as the outgoing one lands + * at alpha 0 β€” a clean sequential cross-fade with no overlap. + */ +private const val FaceHandoffMillis = 150 + +/** + * A glass surface and its controls are siblings on purpose. [viewerGlass] owns a shaped offscreen + * layer, which is exactly what gives the panel its clean rounded material; placing animated buttons + * inside that layer clips their press-deformation at the panel edge. This surface keeps the glass + * clipped and lets the interactive content paint in an overflow-safe layer above it. + */ +@Composable +private fun ViewerGlassOverflowSurface( + modifier: Modifier, + backdrop: LayerBackdrop, + color: Color, + content: @Composable BoxScope.() -> Unit +) { + Box(modifier) { + Box(Modifier.matchParentSize().viewerGlass(backdrop, color)) + Box(Modifier.fillMaxWidth().zIndex(1f), content = content) + } +} + +@Composable +internal fun PdfViewerBottomToolbar( + // display state + activeTool: PdfEditTool, + drawingToolActive: Boolean, + showFindBar: Boolean, + showSignaturePad: Boolean, + activeImageId: Long?, + currentColor: Color, + currentColorLong: Long, + currentStrokeWidth: Float, + zoomScale: Float, + hasEdits: Boolean, + isExporting: Boolean, + exportError: String?, + exportMessage: String?, + lastExportedUri: Uri?, + selectedTextCount: Int, + currentSelectedIds: Set, + activeIsSignature: Boolean, + // Undo/clear are driven by the viewer's own history rather than by a list handed down here: the + // page the user is drawing on is NOT `firstVisibleItemIndex`, so a list picked by the toolbar + // was routinely the wrong one. + canUndo: Boolean, + // callbacks + onUndo: () -> Unit, + onClearPage: () -> Unit, + onSetActiveTool: (PdfEditTool) -> Unit, + onToggleFindBar: () -> Unit, + onShowSignaturePad: () -> Unit, + onPickImage: () -> Unit, + onResetZoom: () -> Unit, + onShowSaveDialog: () -> Unit, + onImageDone: () -> Unit, + onReplaceImage: () -> Unit, + onDeleteImage: () -> Unit, + onSelectAllText: () -> Unit, + onCopyText: () -> Unit, + onHighlightSelected: () -> Unit, + onUnderlineSelected: () -> Unit, + onStrikeSelected: () -> Unit, + onClearTextSelection: () -> Unit, + onSetColorLong: (Long) -> Unit, + onSetStrokeWidth: (Float) -> Unit, + onDismissExportFeedback: () -> Unit, + onOpenExportedFile: () -> Unit, + onOpenAnotherPdf: () -> Unit, + onShareDocument: () -> Unit, + onEditorOpenChanged: (Boolean) -> Unit = {}, + // Same purpose as [onEditorOpenChanged]: a long-press on the share capsule is a gesture the + // viewer cannot see, so without this the 5s chrome auto-hide fires mid-hold and takes the button + // out from under the finger. + onShareHoldChanged: (Boolean) -> Unit = {}, + onRecolorSignature: (Long) -> Unit, + backdrop: LayerBackdrop, + uiSensor: UISensor, + // Adaptive chrome palette (dark ink on light pages, white on dark pages). + fg: Color, + fgSoft: Color, + glass: Color, + chip: Color, + // Original document family (derived from the file name) so tools can adapt β€” e.g. PPT shows a + // "coming soon" placeholder instead of the annotation tools. + docKind: DocKind = DocKind.Pdf +) { + val accent = Color(0xFF1976D2) + + // The floating pills paint nothing at all, exactly as Home's controls do: `LiquidIconButton` is + // called there with no `surfaceColor`, so its `onDrawSurface` is a no-op and the button is pure + // refraction. `drawRect(Color.Transparent)` is the same no-op for the `viewerGlass` surfaces + // here. The header's back and search circles already work this way; without this the Editor + // Tools pill, the tool row and the share capsule were the only chrome left carrying a tint, and + // sitting beside clear circles they read as slabs. + // + // `glass` is deliberately still used for the *panels* (the draw/OCR/image sub-toolbar and the + // export-feedback strip): those are dense rows of controls that need a plate to sit on, and they + // cover the document rather than floating over it. + val pillGlass = Color.Transparent + + val showDrawTools = drawingToolActive + val showOcrTools = activeTool == PdfEditTool.SelectText || selectedTextCount > 0 + val showImageTools = activeTool == PdfEditTool.Image && activeImageId != null + + // The two faces of the toolbar never share the screen: the SELECTOR face (the tool chips + the + // blue "Editor Tools" pill) and the SUB-TOOLBAR face (draw / OCR / image). `subActive` is the + // dimension that swaps them. + val subActive = showDrawTools || showOcrTools || showImageTools + // Apple-style hand-off. The old code removed the selector face INSTANTLY (ExitTransition.None) + // while the sub-toolbar faded in, so the two vanished/appeared on top of each other. Instead we + // run a tiny two-phase gate: the outgoing face fades fully out, and only THEN does the incoming + // face fade in β€” they never overlap in layout, which is also what used to make the sub-toolbar + // open on top and then visibly drop as the pill collapsed. + var selectorGate by remember { mutableStateOf(!subActive) } + var subGate by remember { mutableStateOf(subActive) } + LaunchedEffect(subActive) { + if (subActive) { + selectorGate = false // chips + Editor-Tools pill begin fading out + delay(FaceHandoffMillis.toLong()) // wait for them to clear + subGate = true // sub-toolbar fades in + } else { + subGate = false // sub-toolbar begins fading out + delay(FaceHandoffMillis.toLong()) + selectorGate = true // chips + pill fade back in + } + } + + // Collapsed by default (just the "Editor Tools" pill + "Open PDF" circle). Tapping + // the pill expands the tool set above it. Image selection auto-expands so its tools show. + var editorOpen by remember { mutableStateOf(false) } + // Idle: the tool panels cover the FULL width (scale 1). While the share capsule is morphed up, + // the panels above the Editor-Tools pill COMPRESS horizontally toward the left (scaleX), freeing + // room for the cylinder β€” the whole pill + its buttons shrink together, so nothing is cut. + var shareActive by remember { mutableStateOf(false) } + val toolCompress by animateFloatAsState( + if (shareActive) 0.84f else 1f, + // Bounce, matching the share capsule's own morph β€” both now run on GlassMotion.morph(), so the + // pill springs shut (and back open) with the same weight the cylinder has instead of deflating + // limply beside it. Safe to overshoot because this drives `scaleX`, a DRAW-time property: no + // per-frame re-measure of the glass, unlike a width/height spring (see GlassMotion's KDoc). + GlassMotion.morph(), + label = "toolCompress" + ) + // Tell the viewer when the Editor Tools panel is open so it won't auto-hide the chrome. + LaunchedEffect(editorOpen) { onEditorOpenChanged(editorOpen) } + // Any active tool implies the editor is open (survives the chrome auto-hiding/returning). + LaunchedEffect(activeTool, activeImageId) { + if (activeTool != PdfEditTool.None || activeImageId != null) editorOpen = true + } + + // The share capsule lives as an OVERLAY sibling of the toolbar column inside this wrapper Box. + // Both are bottom-anchored: when the capsule morphs taller than the column, the WRAPPER grows + // (real layout height = strictly upward, no overflow/clip), while the column stays pinned to the + // bottom β€” so the tool panels and the "Editor Tools" pill never move. + Box(Modifier.fillMaxWidth()) { + Column( + Modifier.fillMaxWidth().align(Alignment.BottomCenter).zIndex(2f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + // ── Draw / OCR / Image sub-toolbar ──────────────────────────────── + // Rises ABOVE the pinned main row with an Apple-style spring slide + fade. + // Only this panel moves, so re-blur is confined to one surface. + AnimatedVisibility( + // `subGate` is delayed by [FaceHandoffMillis] after a sub-tool becomes active, so the + // selector face has already faded out before this fades in β€” the two never coexist. + visible = subGate && editorOpen && !showFindBar && !showSignaturePad, + // Fade only for the LAYOUT (one measure), and a draw-time bottom-anchored `scaleY` unfurl + // for the motion β€” the same technique as the Editor-Tools reveal. `expandVertically` here + // re-measured this `viewerGlass` panel every frame and re-ran its blur + lens with it, + // which is why the reveal read as rigid/instant rather than liquid. + enter = fadeIn(tween(200)), + exit = fadeOut(tween(FaceHandoffMillis)) + ) { + val reveal by transition.animateFloat( + transitionSpec = { + if (targetState == EnterExitState.Visible) spring(dampingRatio = 0.72f, stiffness = 300f) + else spring(dampingRatio = 1f, stiffness = Spring.StiffnessMedium) + }, + label = "drawToolsReveal" + ) { if (it == EnterExitState.Visible) 1f else 0f } + ViewerGlassOverflowSurface( + modifier = Modifier.fillMaxWidth() + .graphicsLayer { + scaleY = 0.9f + 0.1f * reveal + transformOrigin = TransformOrigin(0.5f, 1f) + translationY = (1f - reveal) * 8.dp.toPx() + } + .graphicsLayer { scaleX = toolCompress; transformOrigin = TransformOrigin(0f, 0.5f) }, + backdrop = backdrop, + color = glass + ) { + Column( + Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Back to the tool menu (this sub-toolbar is a focused mode). + LiquidIconButton( + onClick = { onSetActiveTool(PdfEditTool.None) }, + backdrop = backdrop, + surfaceColor = Color(0xFFFF6B81).copy(0.9f), + modifier = Modifier.size(40.dp) + ) { CloseCrossIcon(Modifier.size(13.dp), Color.White) } + Box(Modifier.width(1.dp).height(26.dp).background(fg.copy(0.14f))) + // Same curved-edge treatment as the main Editor-Tools carousel: clip to the + // rounded shape (not the scroll's straight rectangular edge) and fade the ends so + // the pen / shapes / OCR buttons slide away behind the capsule curve instead of + // being chopped by a hard vertical line. Content padding keeps the first/last + // button spaced like the rest. + val toolRowScroll = rememberScrollState() + Row( + modifier = Modifier + .weight(1f) + .carouselEdges(toolRowScroll, clipContent = false) + .horizontalScroll(toolRowScroll) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when { + showDrawTools -> { + // Icon toolbar; the active tool fills with the current ink + // colour via a smooth colour cross-fade. + // Word docs only get the Highlight tool here (no pen/shapes) β€” a + // curated markup experience; other doc kinds get the full shape set. + val drawTools = if (docKind == DocKind.Word) listOf( + Triple(PdfEditTool.Highlight, Icons.Rounded.Brush, R.string.viewer_highlight) + ) else listOf( + Triple(PdfEditTool.Draw, Icons.Rounded.Edit, R.string.viewer_pen), + Triple(PdfEditTool.Highlight, Icons.Rounded.Brush, R.string.viewer_highlight), + Triple(PdfEditTool.Rect, Icons.Rounded.CropSquare, R.string.viewer_rect), + Triple(PdfEditTool.Ellipse, Icons.Rounded.RadioButtonUnchecked, R.string.viewer_oval), + Triple(PdfEditTool.Line, Icons.Rounded.Remove, R.string.viewer_line), + Triple(PdfEditTool.Arrow, Icons.AutoMirrored.Rounded.ArrowForward, R.string.viewer_arrow) + ) + drawTools.forEach { (tool, icon, labelRes) -> + val active = activeTool == tool + val surf by animateColorAsState( + if (active) currentColor.copy(0.95f) else chip, + tween(150), label = "toolSurface" + ) + val ink by animateColorAsState(if (active) Color.White else fg, tween(150), label = "toolInk") + LiquidIconButton( + onClick = { onSetActiveTool(tool) }, + backdrop = backdrop, + surfaceColor = surf, + modifier = Modifier.size(40.dp) + ) { Icon(icon, stringResource(labelRes), Modifier.size(19.dp), ink) } + } + Box(Modifier.width(1.dp).height(26.dp).background(fg.copy(0.14f))) + LiquidIconButton( + onClick = onUndo, + backdrop = backdrop, + surfaceColor = chip, + modifier = Modifier.size(40.dp) + ) { Icon(Icons.Rounded.Undo, stringResource(R.string.viewer_undo), Modifier.size(19.dp), fg.copy(if (canUndo) 1f else 0.35f)) } + LiquidIconButton( + onClick = onClearPage, + backdrop = backdrop, + surfaceColor = Color(0xFFC62828).copy(0.85f), + modifier = Modifier.size(40.dp) + ) { Icon(Icons.Rounded.Delete, stringResource(R.string.viewer_clear), Modifier.size(19.dp), Color.White) } + } + + showOcrTools -> { + LiquidButton(onClick = onSelectAllText, backdrop = backdrop, surfaceColor = chip) { + BasicText(stringResource(R.string.viewer_select_all), style = TextStyle(fg, 12.sp, FontWeight.Medium)) + } + LiquidButton(onClick = onCopyText, backdrop = backdrop, tint = Color(0xFF7E57C2)) { + BasicText(stringResource(R.string.copy), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + LiquidButton(onClick = onHighlightSelected, backdrop = backdrop, tint = Color(0xFFFFB300)) { + BasicText(stringResource(R.string.viewer_highlight), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + LiquidButton(onClick = onUnderlineSelected, backdrop = backdrop, tint = Color(0xFF4CAF50)) { + BasicText(stringResource(R.string.viewer_underline), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + LiquidButton(onClick = onStrikeSelected, backdrop = backdrop, tint = Color(0xFFEF5350)) { + BasicText(stringResource(R.string.viewer_strike), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + LiquidButton(onClick = onClearTextSelection, backdrop = backdrop, surfaceColor = chip) { + BasicText(stringResource(R.string.viewer_clear), style = TextStyle(fg, 12.sp, FontWeight.Medium)) + } + } + + showImageTools -> { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + LiquidButton(onClick = onImageDone, backdrop = backdrop, tint = Color(0xFF00C853)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Icon(Icons.Rounded.Check, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.viewer_done), style = TextStyle(Color.White, 14.sp, FontWeight.Bold)) + } + } + + LiquidButton(onClick = onReplaceImage, backdrop = backdrop, tint = Color(0xFF1976D2)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Icon(Icons.Rounded.SwapHoriz, null, Modifier.size(18.dp), Color.White) + BasicText( + if (activeIsSignature) stringResource(R.string.viewer_new_sign) else stringResource(R.string.viewer_replace), + style = TextStyle(Color.White, 13.sp, FontWeight.Medium) + ) + } + } + + LiquidButton(onClick = onDeleteImage, backdrop = backdrop, tint = Color(0xFFEF5350)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Icon(Icons.Rounded.Delete, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.delete), style = TextStyle(Color.White, 13.sp, FontWeight.Medium)) + } + } + } + } + } + } + } + + // Attributes β€” stroke sizes (draw only) + colour beads on ONE line, + // so the toolbar stays compact instead of stacking rows. Same curved-edge fade as the + // tool row above, so the colour beads disappear behind the capsule curve rather than a + // straight cut. + val attrRowScroll = rememberScrollState() + Row( + Modifier + .fillMaxWidth() + .carouselEdges(attrRowScroll, clipContent = false) + .horizontalScroll(attrRowScroll) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (showDrawTools) { + listOf("S" to 3f, "M" to 6f, "L" to 11f, "XL" to 18f).forEach { (label, w) -> + val sel = currentStrokeWidth == w + val surf by animateColorAsState(if (sel) currentColor.copy(0.85f) else chip, tween(150), label = "sizeSurface") + val ink by animateColorAsState(if (sel) Color.White else fg, tween(150), label = "sizeInk") + LiquidButton(onClick = { onSetStrokeWidth(w) }, backdrop = backdrop, surfaceColor = surf) { + BasicText(label, style = TextStyle(ink, 12.sp, FontWeight.Medium)) + } + } + Box(Modifier.width(1.dp).height(24.dp).background(fg.copy(0.14f))) + } + listOf( + 0xFF00BCD4L, 0xFFFFB300L, 0xFF4CAF50L, 0xFFEF5350L, + 0xFF42A5F5L, 0xFFAB47BCL, 0xFF26A69AL, 0xFFE0E0E0L + ).forEach { cl -> + val sel = currentColorLong == cl + LiquidIconButton( + onClick = { onSetColorLong(cl); if (showImageTools) onRecolorSignature(cl) }, + backdrop = backdrop, + surfaceColor = Color(cl), + modifier = Modifier.size(if (sel) 34.dp else 28.dp) + ) { + if (sel) Icon( + Icons.Rounded.Check, null, Modifier.size(15.dp), + if (Color(cl).luminance() > 0.6f) Color.Black.copy(0.7f) else Color.White + ) + } + } + } + } + } + } + + // ── Export feedback row ──────────────────────────────────────────── + if (exportError != null || exportMessage != null || isExporting) { + Row( + Modifier.fillMaxWidth().viewerGlass(backdrop, glass).padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + when { + isExporting -> BasicText(stringResource(R.string.viewer_saving), style = TextStyle(fgSoft, 12.sp)) + exportError != null -> { + BasicText(exportError, style = TextStyle(Color(0xFFE53935), 12.sp)) + LiquidButton(onClick = onDismissExportFeedback, backdrop = backdrop, surfaceColor = chip) { + BasicText(stringResource(R.string.dismiss), style = TextStyle(fg, 11.sp, FontWeight.Medium)) + } + } + exportMessage != null -> { + BasicText(exportMessage, style = TextStyle(Color(0xFFB9F6CA), 12.sp)) + if (lastExportedUri != null) { + LiquidButton(onClick = onOpenExportedFile, backdrop = backdrop, tint = Color(0xFF1976D2)) { + BasicText(stringResource(R.string.open), style = TextStyle(Color.White, 11.sp, FontWeight.Medium)) + } + } + } + } + } + } + + + // ── Tool selector row β€” revealed above the fixed "Editor Tools" bar when + // the editor is open. Hidden once a sub-tool is active (focused mode) so the + // screen isn't stacked with panels β€” the sub-toolbar's βœ• returns here. + AnimatedVisibility( + // Gated by `selectorGate`, which drops the instant a sub-tool becomes active β€” so the + // chips fade out FIRST and the sub-toolbar (held back by `subGate`) only fades in once + // this space is clear. The two faces no longer overlap, so a real fading exit is finally + // safe here: it can hold its layout space while it fades because nothing is fading in on + // top of it yet. + visible = selectorGate && editorOpen && !showFindBar && !showSignaturePad && activeImageId == null, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(FaceHandoffMillis)) + ) { + val reveal by transition.animateFloat( + transitionSpec = { + if (targetState == EnterExitState.Visible) spring(dampingRatio = 0.72f, stiffness = 300f) + else spring(dampingRatio = 1f, stiffness = Spring.StiffnessMedium) + }, + label = "editorToolsReveal" + ) { if (it == EnterExitState.Visible) 1f else 0f } + Box( + Modifier + .graphicsLayer { + scaleY = 0.9f + 0.1f * reveal + transformOrigin = TransformOrigin(0.5f, 1f) + translationY = (1f - reveal) * 8.dp.toPx() + } + .zIndex(1f) + ) { + if (docKind == DocKind.Ppt) { + // PowerPoint editing isn't available yet β€” a friendly placeholder instead of the + // annotation tools (which don't map cleanly onto slides). + ViewerGlassOverflowSurface( + modifier = Modifier.fillMaxWidth() + .graphicsLayer { scaleX = toolCompress; transformOrigin = TransformOrigin(0f, 0.5f) }, + backdrop = backdrop, + color = pillGlass + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Rounded.Slideshow, null, Modifier.size(20.dp), fg) + BasicText(stringResource(R.string.viewer_tools_coming_soon), style = TextStyle(fg, 14.sp, FontWeight.SemiBold)) + } + } + } else if (docKind == DocKind.Word) { + // Curated Word reading/markup set β€” Select Text, Highlight, Find. No PDF-centric + // shapes / add-image / text-box / note / eraser / sign. + val wordScroll = rememberScrollState() + ViewerGlassOverflowSurface( + modifier = Modifier.fillMaxWidth() + .graphicsLayer { scaleX = toolCompress; transformOrigin = TransformOrigin(0f, 0.5f) }, + backdrop = backdrop, + color = pillGlass + ) { + Row( + Modifier + .fillMaxWidth() + // Keep the existing fade mask and scroll behavior; the extra vertical + // breathing room is what lets a pressed chip deform without touching the + // viewport's top or bottom edge. + .carouselEdges(wordScroll, clipContent = false) + .horizontalScroll(wordScroll) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + fun wSurface(color: Color, active: Boolean) = color.copy(if (active) 0.96f else 0.80f) + val selOn = activeTool == PdfEditTool.SelectText + LiquidButton(onClick = { onSetActiveTool(if (selOn) PdfEditTool.None else PdfEditTool.SelectText) }, backdrop = backdrop, surfaceColor = wSurface(Color(0xFF7B1FA2), selOn)) { + BasicText( + if (selectedTextCount > 0) stringResource(R.string.viewer_ocr, selectedTextCount) else stringResource(R.string.viewer_select_text), + style = TextStyle(Color.White, 12.sp, FontWeight.Medium) + ) + } + val hlOn = activeTool == PdfEditTool.Highlight + LiquidButton(onClick = { onSetActiveTool(if (hlOn) PdfEditTool.None else PdfEditTool.Highlight) }, backdrop = backdrop, surfaceColor = wSurface(Color(0xFFF9A825), hlOn)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Brush, null, Modifier.size(14.dp), Color.White) + BasicText(stringResource(R.string.viewer_highlight), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + } + LiquidButton(onClick = onToggleFindBar, backdrop = backdrop, surfaceColor = wSurface(Color(0xFF0277BD), showFindBar)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Search, null, Modifier.size(14.dp), Color.White) + BasicText(stringResource(R.string.viewer_find), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + } + } + } + } else { + val toolScroll = rememberScrollState() + ViewerGlassOverflowSurface( + modifier = Modifier.fillMaxWidth() + .graphicsLayer { scaleX = toolCompress; transformOrigin = TransformOrigin(0f, 0.5f) }, + backdrop = backdrop, + color = pillGlass + ) { + Row( + Modifier + .fillMaxWidth() + // The existing carouselEdges mask remains unchanged; this row is simply + // rendered above the separate glass sibling so its chips can overflow cleanly. + .carouselEdges(toolScroll, clipContent = false) + .horizontalScroll(toolScroll) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Each tool wears its own professional colour (like the Sign button): + // a solid colour chip, brighter when active. White ink reads on all of them. + fun toolSurface(color: Color, active: Boolean) = color.copy(if (active) 0.96f else 0.80f) + + val drawOn = drawingToolActive + LiquidButton( + onClick = { onSetActiveTool(if (drawOn) PdfEditTool.None else PdfEditTool.Draw) }, + backdrop = backdrop, + surfaceColor = toolSurface(Color(0xFF0097A7), drawOn) + ) { BasicText(stringResource(R.string.viewer_draw_tools), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } + + val selOn = activeTool == PdfEditTool.SelectText + LiquidButton( + onClick = { onSetActiveTool(if (selOn) PdfEditTool.None else PdfEditTool.SelectText) }, + backdrop = backdrop, + surfaceColor = toolSurface(Color(0xFF7B1FA2), selOn) + ) { + BasicText( + if (selectedTextCount > 0) stringResource(R.string.viewer_ocr, selectedTextCount) else stringResource(R.string.viewer_select_text), + style = TextStyle(Color.White, 12.sp, FontWeight.Medium) + ) + } + + val imgOn = activeTool == PdfEditTool.Image + LiquidButton( + onClick = { onPickImage() }, + backdrop = backdrop, + surfaceColor = toolSurface(Color(0xFF1565C0), imgOn) + ) { BasicText(stringResource(R.string.viewer_add_image), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } + + val textOn = activeTool == PdfEditTool.Text + LiquidButton( + onClick = { onSetActiveTool(if (textOn) PdfEditTool.None else PdfEditTool.Text) }, + backdrop = backdrop, + surfaceColor = toolSurface(Color(0xFF00796B), textOn) + ) { BasicText(stringResource(R.string.anno_text_title), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } + + val noteOn = activeTool == PdfEditTool.Note + LiquidButton( + onClick = { onSetActiveTool(if (noteOn) PdfEditTool.None else PdfEditTool.Note) }, + backdrop = backdrop, + surfaceColor = toolSurface(Color(0xFFEF6C00), noteOn) + ) { BasicText(stringResource(R.string.anno_note_title), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } + + val eraseOn = activeTool == PdfEditTool.Eraser + LiquidButton( + onClick = { onSetActiveTool(if (eraseOn) PdfEditTool.None else PdfEditTool.Eraser) }, + backdrop = backdrop, + surfaceColor = toolSurface(Color(0xFFC62828), eraseOn) + ) { BasicText(stringResource(R.string.viewer_eraser), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } + + LiquidButton(onClick = onShowSignaturePad, backdrop = backdrop, surfaceColor = toolSurface(Color(0xFF5E35B1), false)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Gesture, null, Modifier.size(14.dp), Color.White) + BasicText(stringResource(R.string.viewer_sign), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + } + + LiquidButton( + onClick = onToggleFindBar, + backdrop = backdrop, + surfaceColor = toolSurface(Color(0xFF0277BD), showFindBar) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Rounded.Search, null, Modifier.size(14.dp), Color.White) + BasicText(stringResource(R.string.viewer_find), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + } + + // Undo used to live only inside the draw strip, so placing a text box, a note, an + // image or a signature left nothing to undo with. The draw strip has its own copy, + // hence the exclusion here. + if (canUndo && !drawingToolActive) { + LiquidButton(onClick = onUndo, backdrop = backdrop, surfaceColor = chip) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Rounded.Undo, null, Modifier.size(14.dp), fg) + BasicText(stringResource(R.string.viewer_undo), style = TextStyle(fg, 12.sp, FontWeight.Medium)) + } + } + } + + if (drawingToolActive || activeTool == PdfEditTool.SelectText || activeTool == PdfEditTool.Image || activeTool == PdfEditTool.Eraser || activeTool == PdfEditTool.Text || activeTool == PdfEditTool.Note) { + LiquidIconButton( + onClick = { onSetActiveTool(PdfEditTool.None) }, + backdrop = backdrop, + tint = Color(0xFFEF5350), + modifier = Modifier.size(32.dp) + ) { CloseCrossIcon(Modifier.size(14.dp), Color.White) } + } + + if (zoomScale > 1.01f) { + LiquidButton(onClick = onResetZoom, backdrop = backdrop, surfaceColor = chip) { + BasicText( + stringResource(R.string.viewer_reset_zoom, (zoomScale * 100 + 0.5f).toInt()), + style = TextStyle(fg, 12.sp, FontWeight.Medium) + ) + } + } + + if (hasEdits && !isExporting) { + LiquidButton(onClick = onShowSaveDialog, backdrop = backdrop, tint = Color(0xFF1976D2)) { + BasicText(stringResource(R.string.viewer_save_edits), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + } + } + } + } + } + } + } + + // ── Collapsed home bar: one "Editor Tools" pill (expands the tools above it) + // + a compact circular "Open another PDF" button. Hidden while a sub-tool is + // active so that focused mode shows ONLY the sub-toolbar (one panel). + AnimatedVisibility( + // Same `selectorGate` as the tool chips above, so the blue "Editor Tools" pill fades out + // in lockstep with them β€” "both pills at the same time" β€” before the sub-toolbar arrives. + visible = selectorGate && !showFindBar && !showSignaturePad, + enter = fadeIn(tween(180)), + exit = fadeOut(tween(FaceHandoffMillis)) + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + // Bottom-align so the "Editor Tools" pill stays pinned to the toolbar's base while + // the share capsule (whose real layout height grows) extends UPWARD only β€” the + // toolbar column is bottom-anchored on screen, so added height goes up. + verticalAlignment = Alignment.Bottom + ) { + LiquidButton( + onClick = { + editorOpen = !editorOpen + if (!editorOpen) onSetActiveTool(PdfEditTool.None) + }, + backdrop = backdrop, + tint = if (editorOpen) accent else Color.Unspecified, + surfaceColor = if (editorOpen) Color.Unspecified else pillGlass, + modifier = Modifier.weight(1f) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp) + ) { + Icon(Icons.Rounded.Edit, null, Modifier.size(18.dp), if (editorOpen) Color.White else fg) + BasicText( + stringResource(R.string.viewer_editor_tools), + style = TextStyle(if (editorOpen) Color.White else fg, 14.sp, FontWeight.SemiBold), + maxLines = 1 + ) + } + } + // Reserve the circle's slot at the home-bar level so the pill never sits under the + // share button. (The tool panels above cover full width and only FADE on morph.) + Spacer(Modifier.size(52.dp)) + } + } + } + + // ── Share capsule OVERLAY ────────────────────────────────────────── + // Sits over the reserved slot at the bottom-right. Because it's a sibling of the column + // (not inside it) and bottom-anchored, morphing it taller grows only this wrapper Box + // upward β€” the column (tool panels + pill) stays pinned to the base and never moves. + AnimatedVisibility( + // Sits in the pill row's reserved slot, so it rides the same `selectorGate` and fades out + // alongside the pill instead of popping away on its own. + visible = selectorGate && !showFindBar && !showSignaturePad, + enter = fadeIn(tween(180)), + exit = fadeOut(tween(FaceHandoffMillis)), + modifier = Modifier.align(Alignment.BottomEnd).zIndex(3f) + ) { + ShareMorphButton( + backdrop = backdrop, + glass = pillGlass, + fg = fg, + onOpen = onOpenAnotherPdf, + onShare = onShareDocument, + onShareModeChanged = { shareActive = it; onShareHoldChanged(it) } + ) + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt new file mode 100644 index 0000000..be3274a --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt @@ -0,0 +1,690 @@ +package com.chethan616.clearpdf.ui.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.DestructiveGlassButton +import com.chethan616.clearpdf.ui.components.GlassMotion +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.utils.UISensor +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +/** + * Page-jump popup rendered **in-window** (not a [Dialog]) so the liquid-glass panel + * can actually sample the viewer backdrop instead of falling back to a grey box. + * Enters with a smooth Apple-style glass "pop" (scale + fade, gentle damping) and + * dims the page behind it with a tap-to-dismiss scrim. + */ +@Composable +internal fun LiquidPageJumpPopup( + visible: Boolean, + currentPage: Int, + pageCount: Int, + backdrop: LayerBackdrop, + uiSensor: UISensor, + // Adaptive chrome palette (dark ink on light pages, white on dark pages). + fg: Color, + fgSoft: Color, + surface: Color, + field: Color, + onDismiss: () -> Unit, + onJumpToPage: (Int) -> Unit +) { + var targetText by remember(visible, currentPage) { mutableStateOf((currentPage + 1).toString()) } + + Box(Modifier.fillMaxSize()) { + // Dimming scrim β€” tap outside to dismiss. + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(180)), + modifier = Modifier.fillMaxSize() + ) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(0.45f)) + .pointerInput(Unit) { detectTapGestures { onDismiss() } } + ) + } + + // Glass popup panel β€” springy but non-bouncy scale-in. + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(220)) + + scaleIn( + initialScale = 0.85f, + animationSpec = spring(dampingRatio = 0.72f, stiffness = Spring.StiffnessMediumLow) + ), + exit = fadeOut(tween(140)) + scaleOut(targetScale = 0.9f, animationSpec = tween(150)), + modifier = Modifier.align(Alignment.Center).imePadding() + ) { + Column( + Modifier + .widthIn(max = 300.dp) + .fillMaxWidth(0.78f) + .liquidGlassPanel(backdrop, uiSensor, surface) + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BasicText( + stringResource(R.string.viewer_jump_to_page), + style = TextStyle(fg, 16.sp, fontWeight = FontWeight.Bold) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + BasicTextField( + value = targetText, + onValueChange = { targetText = it.filter { c -> c.isDigit() } }, + textStyle = TextStyle(fg, 18.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), + singleLine = true, + cursorBrush = SolidColor(fg), + modifier = Modifier + .width(96.dp) + .clip(RoundedCornerShape(14.dp)) + .background(field) + .border(1.dp, fg.copy(0.22f), RoundedCornerShape(14.dp)) + .padding(horizontal = 12.dp, vertical = 12.dp) + ) + + BasicText( + "/ $pageCount", + style = TextStyle(fgSoft, 16.sp, fontWeight = FontWeight.Medium) + ) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End) + ) { + LiquidButton(onClick = onDismiss, backdrop = backdrop, surfaceColor = field) { + BasicText(stringResource(R.string.cancel), style = TextStyle(fg, 13.sp, FontWeight.Medium)) + } + LiquidButton( + onClick = { + val p = targetText.toIntOrNull()?.minus(1)?.coerceIn(0, pageCount - 1) + if (p != null) onJumpToPage(p) + }, + backdrop = backdrop, + tint = Color(0xFF1976D2) + ) { + BasicText(stringResource(R.string.viewer_go), style = TextStyle(Color.White, 13.sp, FontWeight.Bold)) + } + } + } + } + } +} + +/** + * Editor for an inserted text box or sticky note. Rendered IN-WINDOW (not a [Dialog]) + * so the glass panel samples the real page content instead of the wallpaper PNG. + * Adaptive colours keep it readable over any page; fade-only so glass never re-blurs + * mid-transition. Lifts above the keyboard via [imePadding]. + */ +@Composable +internal fun AnnotationEditorDialog( + isNote: Boolean, + initialText: String, + initialColor: Color, + backdrop: LayerBackdrop, + uiSensor: UISensor, + fg: Color, + fgSoft: Color, + surface: Color, + field: Color, + onDismiss: () -> Unit, + onDelete: () -> Unit, + onSave: (String, Color) -> Unit +) { + var text by remember { mutableStateOf(initialText) } + var color by remember { mutableStateOf(initialColor) } + val focus = remember { FocusRequester() } + var shown by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { shown = true; delay(160); runCatching { focus.requestFocus() } } + + Box(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = shown, + enter = fadeIn(tween(180)), + exit = fadeOut(tween(140)), + modifier = Modifier.fillMaxSize() + ) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(0.45f)) + .pointerInput(Unit) { detectTapGestures { onDismiss() } } + ) + } + + AnimatedVisibility( + visible = shown, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(140)), + modifier = Modifier.align(Alignment.Center).fillMaxWidth().imePadding() + ) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column( + Modifier + .fillMaxWidth(0.9f) + .widthIn(max = 440.dp) + .liquidGlassPanel(backdrop, uiSensor, surface) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + BasicText( + if (isNote) stringResource(R.string.anno_note_title) else stringResource(R.string.anno_text_title), + style = TextStyle(fg, 16.sp, fontWeight = FontWeight.Bold) + ) + + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 54.dp) + .clip(RoundedCornerShape(12.dp)) + .background(field) + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + if (text.isEmpty()) { + BasicText( + stringResource(R.string.anno_hint), + style = TextStyle(fgSoft, 14.sp) + ) + } + BasicTextField( + value = text, + onValueChange = { text = it }, + textStyle = TextStyle(fg, 14.sp), + cursorBrush = SolidColor(fg), + modifier = Modifier.fillMaxWidth().focusRequester(focus) + ) + } + + // Colour picker β€” recolour the text / sticky note. + AnnotationColorRow(selected = color, fgSoft = fgSoft, onPick = { color = it }) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + DestructiveGlassButton(stringResource(R.string.delete), onDelete, backdrop) + LiquidButton(onClick = onDismiss, backdrop = backdrop, surfaceColor = field) { + BasicText(stringResource(R.string.cancel), style = TextStyle(fg, 13.sp)) + } + LiquidButton(onClick = { onSave(text, color) }, backdrop = backdrop, tint = Color(0xFF1976D2)) { + BasicText(stringResource(R.string.anno_save), style = TextStyle(Color.White, 13.sp, FontWeight.Bold)) + } + } + } + } + } + } +} + +/** Which file the viewer's Share action should hand off. */ +internal enum class ShareFormat { ORIGINAL, PDF } + +/** + * Share/export chooser, rendered in-window in the same glass family as [LiquidPageJumpPopup]. For a + * converted document (a .docx opened as a PDF) it offers the original file *or* a PDF via a bouncy + * [LiquidGlassDropdown]; when PDF is the target it can encrypt with a password. A plain PDF skips the + * format row and just offers the encrypt toggle. + * + * The dialog only collects intent β€” the actual file work (encrypt, wrap, chooser) happens off the UI + * thread in the caller. + */ +@Composable +internal fun ExportShareDialog( + visible: Boolean, + // Uppercase token for the original file when it isn't a PDF (e.g. "DOCX"); null for a plain PDF. + originalExt: String?, + backdrop: LayerBackdrop, + uiSensor: UISensor, + fg: Color, + fgSoft: Color, + surface: Color, + field: Color, + onDismiss: () -> Unit, + onShare: (format: ShareFormat, encrypt: Boolean, password: String) -> Unit +) { + // Keyed on `visible` so every open starts fresh. + var formatIndex by remember(visible) { mutableStateOf(0) } + var encrypt by remember(visible) { mutableStateOf(false) } + var password by remember(visible) { mutableStateOf("") } + var dropdownOpen by remember(visible) { mutableStateOf(false) } + val passwordFocus = remember { FocusRequester() } + val density = LocalDensity.current + + val pdfOnly = originalExt == null + val pdfSelected = pdfOnly || formatIndex == 1 + val canShare = !(pdfSelected && encrypt && password.isBlank()) + val options = if (pdfOnly) emptyList() else listOf(originalExt, stringResource(R.string.viewer_share_pdf)) + + // Trigger anchor in the dialog Box's coordinate space, so the dropdown menu is an OVERLAY that + // never grows the glass panel. Growing that panel re-runs its blur+lens every frame β€” the exact + // "expand/minimize lag" being fixed here. + var boxWin by remember { mutableStateOf(Offset.Zero) } + var trigWin by remember { mutableStateOf(Offset.Zero) } + var trigSize by remember { mutableStateOf(IntSize.Zero) } + + val chevron by animateFloatAsState(if (dropdownOpen) 180f else 0f, GlassMotion.morph(), label = "shareDropChevron") + + LaunchedEffect(encrypt, pdfSelected) { + if (encrypt && pdfSelected) { delay(120); runCatching { passwordFocus.requestFocus() } } + } + + Box(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(180)), + modifier = Modifier.fillMaxSize() + ) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(0.45f)) + .pointerInput(Unit) { detectTapGestures { onDismiss() } } + ) + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(220)) + + scaleIn(initialScale = 0.85f, animationSpec = spring(dampingRatio = 0.72f, stiffness = Spring.StiffnessMediumLow)), + exit = fadeOut(tween(140)) + scaleOut(targetScale = 0.9f, animationSpec = tween(150)), + modifier = Modifier.align(Alignment.Center).imePadding() + ) { + Box( + Modifier + .widthIn(max = 340.dp) + .fillMaxWidth(0.86f) + .onGloballyPositioned { boxWin = it.positionInWindow() } + ) { + Column( + Modifier + .fillMaxWidth() + .liquidGlassPanel(backdrop, uiSensor, surface) + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + BasicText( + stringResource(R.string.viewer_share_title), + style = TextStyle(fg, 17.sp, fontWeight = FontWeight.Bold) + ) + + if (!pdfOnly) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + BasicText( + stringResource(R.string.viewer_share_format), + style = TextStyle(fgSoft, 12.sp, fontWeight = FontWeight.Medium) + ) + // Trigger only β€” the menu is drawn as a dialog overlay below (so the panel + // never resizes). Reports its window position + size to anchor that menu. + LiquidButton( + onClick = { dropdownOpen = !dropdownOpen }, + backdrop = backdrop, + surfaceColor = field, + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { trigWin = it.positionInWindow(); trigSize = it.size } + ) { + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + BasicText( + options.getOrElse(formatIndex) { "" }, + style = TextStyle(fg, 14.sp, fontWeight = FontWeight.SemiBold) + ) + Icon( + Icons.Rounded.KeyboardArrowDown, + null, + Modifier.size(20.dp).graphicsLayer { rotationZ = chevron }, + fgSoft + ) + } + } + } + } + + if (pdfSelected) { + // Segmented Normal | Encrypted. Deterministic β€” a plain tap on `LiquidToggle` + // fires both its clickable and its drag-stop, cancelling out, so it could get + // stuck on and never prompt for a password. Two buttons set a definite value. + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + listOf(false to R.string.viewer_share_normal, true to R.string.viewer_share_encrypted) + .forEach { (enc, labelRes) -> + val sel = encrypt == enc + LiquidButton( + onClick = { encrypt = enc }, + backdrop = backdrop, + tint = if (sel) Color(0xFF1976D2) else Color.Unspecified, + surfaceColor = if (sel) Color.Unspecified else field, + modifier = Modifier.weight(1f) + ) { + BasicText( + stringResource(labelRes), + style = TextStyle(if (sel) Color.White else fg, 13.sp, fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(vertical = 2.dp) + ) + } + } + } + if (encrypt) { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(field) + .border(1.dp, fg.copy(0.18f), RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 12.dp) + ) { + if (password.isEmpty()) { + BasicText( + stringResource(R.string.viewer_share_password_hint), + style = TextStyle(fgSoft, 14.sp) + ) + } + BasicTextField( + value = password, + onValueChange = { password = it }, + singleLine = true, + textStyle = TextStyle(fg, 14.sp), + cursorBrush = SolidColor(fg), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth().focusRequester(passwordFocus) + ) + } + } + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End) + ) { + LiquidButton(onClick = onDismiss, backdrop = backdrop, surfaceColor = field) { + BasicText(stringResource(R.string.cancel), style = TextStyle(fg, 13.sp, FontWeight.Medium)) + } + LiquidButton( + onClick = { + if (canShare) onShare( + if (pdfSelected) ShareFormat.PDF else ShareFormat.ORIGINAL, + encrypt && pdfSelected, + password + ) + }, + backdrop = backdrop, + tint = if (canShare) Color(0xFF1976D2) else Color(0xFF1976D2).copy(0.4f) + ) { + BasicText(stringResource(R.string.viewer_share_button), style = TextStyle(Color.White, 13.sp, fontWeight = FontWeight.Bold)) + } + } + } + + // Tap-outside catcher for an open dropdown. `matchParentSize` reads the panel's size + // without contributing to it, so it doesn't grow the dialog. + if (dropdownOpen) { + Box( + Modifier + .matchParentSize() + .pointerInput(Unit) { detectTapGestures { dropdownOpen = false } } + ) + } + + // Dropdown menu overlay β€” anchored under the trigger, drawn last (on top), and NOT + // wrapped by the glass panel, so opening/closing it costs nothing on the panel. The + // bounce is all draw-time (a `pop()` spring driving alpha + a translationY overshoot + + // a small scaleY grow), so the menu's own glass never re-blurs while it springs. + if (!pdfOnly && (dropdownOpen || trigSize != IntSize.Zero)) { + val menuOffset = IntOffset( + (trigWin.x - boxWin.x).roundToInt(), + (trigWin.y - boxWin.y).roundToInt() + trigSize.height + with(density) { 6.dp.roundToPx() } + ) + val menuWidth = with(density) { trigSize.width.toDp() } + AnimatedVisibility( + visible = dropdownOpen, + enter = fadeIn(tween(110)), + exit = fadeOut(tween(90)), + modifier = Modifier.align(Alignment.TopStart).offset { menuOffset }.zIndex(1f) + ) { + val reveal by transition.animateFloat( + transitionSpec = { + if (targetState == EnterExitState.Visible) GlassMotion.pop() else GlassMotion.settle() + }, + label = "shareMenuReveal" + ) { if (it == EnterExitState.Visible) 1f else 0f } + Column( + Modifier + .width(menuWidth) + .graphicsLayer { + alpha = reveal.coerceIn(0f, 1f) + translationY = (1f - reveal) * (-14.dp.toPx()) + scaleY = 0.9f + 0.1f * reveal + transformOrigin = TransformOrigin(0.5f, 0f) + } + .liquidGlassPanel(backdrop, uiSensor, field) + .padding(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + options.forEachIndexed { i, opt -> + val selected = i == formatIndex + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { formatIndex = i; dropdownOpen = false } + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + BasicText( + opt, + style = TextStyle(fg, 14.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium) + ) + if (selected) Icon(Icons.Rounded.Check, null, Modifier.size(16.dp), fg) + } + } + } + } + } + } + } + } +} + +// Shared annotation / shape colour palette (dark inks read well on white pages; a few +// vivid accents for shapes and notes). +internal val editorPalette: List = listOf( + Color(0xFF1A1A1A), // near-black ink + Color(0xFF1976D2), // blue + Color(0xFFE53935), // red + Color(0xFF43A047), // green + Color(0xFFFB8C00), // orange + Color(0xFF8E24AA), // purple + Color(0xFF00ACC1), // teal + Color(0xFFFFC107) // amber (notes) +) + +/** A horizontal row of tappable colour beads; the selected one gets a ring. */ +@Composable +internal fun AnnotationColorRow( + selected: Color, + fgSoft: Color, + onPick: (Color) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + BasicText(stringResource(R.string.viewer_color), style = TextStyle(fgSoft, 12.sp, FontWeight.Medium)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + editorPalette.forEach { c -> + val isSel = c.value == selected.value + Box( + Modifier + .size(if (isSel) 30.dp else 26.dp) + .clip(CircleShape) + .background(c) + .border( + width = if (isSel) 2.5.dp else 1.dp, + color = if (isSel) Color.White else Color.White.copy(0.25f), + shape = CircleShape + ) + .clickable { onPick(c) } + ) + } + } + } +} + +/** + * Contextual editor for a placed shape (rect / oval / line / arrow / stroke). Rendered + * IN-WINDOW so its glass panel samples the real page. Recolours live as the user taps a + * bead, and offers Delete / Done. Same visual family as [AnnotationEditorDialog]. + */ +@Composable +internal fun ShapeEditorPopup( + initialColor: Color, + backdrop: LayerBackdrop, + uiSensor: UISensor, + fg: Color, + fgSoft: Color, + surface: Color, + field: Color, + onColorChange: (Color) -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit +) { + var color by remember { mutableStateOf(initialColor) } + var shown by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { shown = true } + + Box(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = shown, + enter = fadeIn(tween(180)), + exit = fadeOut(tween(140)), + modifier = Modifier.fillMaxSize() + ) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(0.45f)) + .pointerInput(Unit) { detectTapGestures { onDismiss() } } + ) + } + + AnimatedVisibility( + visible = shown, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(140)), + modifier = Modifier.align(Alignment.Center).fillMaxWidth().imePadding() + ) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Column( + Modifier + .fillMaxWidth(0.9f) + .widthIn(max = 440.dp) + .liquidGlassPanel(backdrop, uiSensor, surface) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + BasicText( + stringResource(R.string.viewer_edit_shape), + style = TextStyle(fg, 16.sp, fontWeight = FontWeight.Bold) + ) + + AnnotationColorRow(selected = color, fgSoft = fgSoft, onPick = { color = it; onColorChange(it) }) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + DestructiveGlassButton(stringResource(R.string.delete), onDelete, backdrop) + LiquidButton(onClick = onDismiss, backdrop = backdrop, tint = Color(0xFF1976D2)) { + BasicText(stringResource(R.string.viewer_done), style = TextStyle(Color.White, 13.sp, FontWeight.Bold)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt new file mode 100644 index 0000000..074ce7d --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt @@ -0,0 +1,790 @@ +package com.chethan616.clearpdf.ui.screen + +import android.graphics.Bitmap +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb +import com.chethan616.clearpdf.ui.viewmodel.ExportOverlay +import com.chethan616.clearpdf.ui.viewmodel.FindMatch +import com.chethan616.clearpdf.ui.viewmodel.NormalizedPoint +import com.chethan616.clearpdf.ui.viewmodel.OcrTextBlock +import com.chethan616.clearpdf.ui.viewmodel.OcrTextRange +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +// ── Viewer local enums ──────────────────────────────────────────────────────── + +internal enum class ScrollOrientation { Vertical, Horizontal } + +internal enum class PdfEditTool { None, Draw, Highlight, Rect, Ellipse, Line, Arrow, SelectText, Image, Eraser, Text, Note } + +internal enum class ViewerToolbarMode { Main, Drawing, Selection, Image, Eraser, Search, Signature } + +// ── PdfMarkup β€” on-canvas annotation model ──────────────────────────────────── + +internal sealed class PdfMarkup { + data class StrokeMarkup( + val points: List, + val color: Color, + val width: Float, + val alpha: Float = 1f + ) : PdfMarkup() + + data class RectMarkup( + val start: Offset, + val end: Offset, + val color: Color, + val alpha: Float = 1f, + val filled: Boolean = false + ) : PdfMarkup() + + data class OvalMarkup( + val start: Offset, + val end: Offset, + val color: Color, + val alpha: Float = 1f, + val filled: Boolean = false + ) : PdfMarkup() + + data class LineMarkup( + val start: Offset, + val end: Offset, + val color: Color, + val width: Float = 3f, + val alpha: Float = 1f, + val arrowHead: Boolean = false + ) : PdfMarkup() + + data class TextBlockHighlightMarkup( + val blockId: String, + val color: Color, + val alpha: Float = 0.30f, + val start: Int = 0, + val end: Int = -1 + ) : PdfMarkup() + + data class TextBlockLineMarkup( + val blockId: String, + val color: Color, + val width: Float = 3f, + val alpha: Float = 1f, + val strikeThrough: Boolean = false, + val start: Int = 0, + val end: Int = -1 + ) : PdfMarkup() + + data class ImageMarkup( + val id: Long, + val bitmap: Bitmap, + val start: Offset, + val end: Offset, + val isSignature: Boolean = false + ) : PdfMarkup() + + /** Inserted text box. [position] is content-space top-left; [fontSize] is content px. */ + data class TextBoxMarkup( + val id: Long, + val position: Offset, + val text: String, + val color: Color, + val fontSize: Float = 40f + ) : PdfMarkup() + + /** Sticky note. [anchor] is the content-space top-left of the icon. */ + data class NoteMarkup( + val id: Long, + val anchor: Offset, + val text: String, + val color: Color + ) : PdfMarkup() + + fun hitTest(p: Offset): Boolean = when (this) { + is StrokeMarkup -> points.any { (it - p).getDistance() <= width.coerceAtLeast(16f) } + is RectMarkup -> { + val r = Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) + r.contains(p) + } + is OvalMarkup -> { + val r = Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) + r.contains(p) + } + is LineMarkup -> { + val d = distToSegment(p, start, end) + d <= width.coerceAtLeast(16f) + } + // OCR-anchored markups are hit-tested with the page's OCR geometry. Keep the + // geometry-free overload conservative so callers that do not have that geometry + // cannot accidentally select a whole page-sized annotation. + is TextBlockHighlightMarkup -> false + is TextBlockLineMarkup -> false + is ImageMarkup -> { + val r = Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) + r.contains(p) + } + is TextBoxMarkup -> { + val lines = if (text.isEmpty()) 1 else text.split("\n").size + val w = (text.split("\n").maxOfOrNull { it.length } ?: 1).coerceAtLeast(1) * fontSize * 0.6f + val r = Rect(position.x - 6f, position.y - 6f, position.x + w + 6f, position.y + fontSize * 1.2f * lines + 6f) + r.contains(p) + } + is NoteMarkup -> { + val r = Rect(anchor.x - 8f, anchor.y - 8f, anchor.x + 40f, anchor.y + 40f) + r.contains(p) + } + } +} + +/** Recolourable free-form shapes (as opposed to images / OCR-anchored / text markups). */ +internal fun PdfMarkup.isShape(): Boolean = this is PdfMarkup.StrokeMarkup || + this is PdfMarkup.RectMarkup || this is PdfMarkup.OvalMarkup || this is PdfMarkup.LineMarkup + +/** Markups that support the generic select β†’ move / resize transform (everything the user + * places freely, except images which have their own dedicated toolbar path). */ +internal fun PdfMarkup.isTransformable(): Boolean = this is PdfMarkup.StrokeMarkup || + this is PdfMarkup.RectMarkup || this is PdfMarkup.OvalMarkup || this is PdfMarkup.LineMarkup || + this is PdfMarkup.TextBoxMarkup || this is PdfMarkup.NoteMarkup + +/** Whether a bottom-right resize handle applies (notes are a fixed-size icon β†’ move only). */ +internal fun PdfMarkup.isResizable(): Boolean = isTransformable() && this !is PdfMarkup.NoteMarkup + +/** Content-space bounding box used for the selection frame + hit-testing during transform. */ +internal fun PdfMarkup.movableBounds(): Rect? = when (this) { + is PdfMarkup.StrokeMarkup -> { + if (points.isEmpty()) null else { + var l = points[0].x; var t = points[0].y; var r = l; var b = t + points.forEach { l = min(l, it.x); t = min(t, it.y); r = max(r, it.x); b = max(b, it.y) } + Rect(l, t, max(r, l + 1f), max(b, t + 1f)) + } + } + is PdfMarkup.RectMarkup -> Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) + is PdfMarkup.OvalMarkup -> Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) + is PdfMarkup.LineMarkup -> Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x) + 1f, max(start.y, end.y) + 1f) + is PdfMarkup.ImageMarkup -> Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) + is PdfMarkup.TextBoxMarkup -> { + val lines = if (text.isEmpty()) 1 else text.split("\n").size + val w = ((text.split("\n").maxOfOrNull { it.length } ?: 1).coerceAtLeast(3)) * fontSize * 0.6f + Rect(position.x, position.y, position.x + w, position.y + fontSize * 1.2f * lines) + } + is PdfMarkup.NoteMarkup -> Rect(anchor.x, anchor.y, anchor.x + 30f, anchor.y + 30f) + else -> null +} + +/** Translate a markup by [d] (move). */ +internal fun PdfMarkup.translated(d: Offset): PdfMarkup = when (this) { + is PdfMarkup.StrokeMarkup -> copy(points = points.map { it + d }) + is PdfMarkup.RectMarkup -> copy(start = start + d, end = end + d) + is PdfMarkup.OvalMarkup -> copy(start = start + d, end = end + d) + is PdfMarkup.LineMarkup -> copy(start = start + d, end = end + d) + is PdfMarkup.ImageMarkup -> copy(start = start + d, end = end + d) + is PdfMarkup.TextBoxMarkup -> copy(position = position + d) + is PdfMarkup.NoteMarkup -> copy(anchor = anchor + d) + else -> this +} + +/** Resize a markup by dragging its bottom-right handle [drag], given its current [bounds]. */ +internal fun PdfMarkup.resizedBy(drag: Offset, bounds: Rect): PdfMarkup = when (this) { + is PdfMarkup.RectMarkup -> copy( + start = bounds.topLeft, + end = Offset((bounds.right + drag.x).coerceAtLeast(bounds.left + 8f), (bounds.bottom + drag.y).coerceAtLeast(bounds.top + 8f)) + ) + is PdfMarkup.OvalMarkup -> copy( + start = bounds.topLeft, + end = Offset((bounds.right + drag.x).coerceAtLeast(bounds.left + 8f), (bounds.bottom + drag.y).coerceAtLeast(bounds.top + 8f)) + ) + is PdfMarkup.LineMarkup -> { + // Move whichever endpoint sits nearer the bottom-right handle. + val br = bounds.bottomRight + if ((end - br).getDistance() <= (start - br).getDistance()) copy(end = end + drag) else copy(start = start + drag) + } + is PdfMarkup.StrokeMarkup -> { + // Uniform scale about the top-left so the stroke keeps its shape. + val pivot = bounds.topLeft + val fx = ((bounds.width + drag.x) / bounds.width.coerceAtLeast(1f)).coerceIn(0.2f, 8f) + val fy = ((bounds.height + drag.y) / bounds.height.coerceAtLeast(1f)).coerceIn(0.2f, 8f) + val f = (fx + fy) / 2f + copy(points = points.map { pivot + (it - pivot) * f }) + } + is PdfMarkup.TextBoxMarkup -> { + val factor = ((bounds.height + drag.y) / bounds.height.coerceAtLeast(1f)).coerceIn(0.3f, 6f) + copy(fontSize = (fontSize * factor).coerceIn(10f, 400f)) + } + else -> this +} + +internal fun PdfMarkup.shapeColor(): Color = when (this) { + is PdfMarkup.StrokeMarkup -> color + is PdfMarkup.RectMarkup -> color + is PdfMarkup.OvalMarkup -> color + is PdfMarkup.LineMarkup -> color + else -> Color(0xFF1976D2) +} + +internal fun PdfMarkup.recolored(c: Color): PdfMarkup = when (this) { + is PdfMarkup.StrokeMarkup -> copy(color = c) + is PdfMarkup.RectMarkup -> copy(color = c) + is PdfMarkup.OvalMarkup -> copy(color = c) + is PdfMarkup.LineMarkup -> copy(color = c) + else -> this +} + +// ── Geometry helpers ────────────────────────────────────────────────────────── + +internal fun fitBitmapRect(canvasSize: Size, bitmapW: Float, bitmapH: Float): Rect { + if (canvasSize.width <= 0f || canvasSize.height <= 0f || bitmapW <= 0f || bitmapH <= 0f) + return Rect(0f, 0f, canvasSize.width, canvasSize.height) + + val canvasRatio = canvasSize.width / canvasSize.height + val bitmapRatio = bitmapW / bitmapH + + val (w, h) = if (canvasRatio > bitmapRatio) { + val h1 = canvasSize.height + val w1 = h1 * bitmapRatio + Pair(w1, h1) + } else { + val w1 = canvasSize.width + val h1 = w1 / bitmapRatio + Pair(w1, h1) + } + val left = (canvasSize.width - w) / 2f + val top = (canvasSize.height - h) / 2f + return Rect(left, top, left + w, top + h) +} + +internal fun screenToContent( + screen: Offset, + zoomScale: Float, + panOffset: Offset, + boxCenter: Offset +): Offset { + val unpanned = screen - panOffset + val rel = unpanned - boxCenter + return (rel / zoomScale) + boxCenter +} + +internal fun clampPanOffset( + pan: Offset, + scale: Float, + canvasSize: Size, + bitmapSize: Size +): Offset { + if (scale <= 1.01f || canvasSize.width <= 0f || canvasSize.height <= 0f) return Offset.Zero + val frame = fitBitmapRect(canvasSize, bitmapSize.width, bitmapSize.height) + val maxPanX = (frame.width * (scale - 1f) / 2f).coerceAtLeast(0f) + val maxPanY = (frame.height * (scale - 1f) / 2f).coerceAtLeast(0f) + return Offset( + pan.x.coerceIn(-maxPanX, maxPanX), + pan.y.coerceIn(-maxPanY, maxPanY) + ) +} + +internal fun ocrBlockToRect(block: OcrTextBlock, frame: Rect): Rect = Rect( + frame.left + block.left * frame.width, + frame.top + block.top * frame.height, + frame.left + block.right * frame.width, + frame.top + block.bottom * frame.height +) + +internal fun OcrTextBlock.wordRanges(): List { + if (text.isEmpty()) return emptyList() + val ranges = mutableListOf() + var index = 0 + while (index < text.length) { + while (index < text.length && text[index].isWhitespace()) index++ + if (index >= text.length) break + val start = index + while (index < text.length && !text[index].isWhitespace()) index++ + ranges += start..(index - 1) + } + return ranges +} + +internal fun ocrTextRangeToRect(block: OcrTextBlock, range: OcrTextRange, frame: Rect): Rect { + val start = range.start.coerceIn(0, block.text.length) + val end = (if (range.end < 0) block.text.length else range.end) + .coerceIn(start, block.text.length) + if (start >= end || block.charLefts.size < end || block.charRights.size < end) { + return ocrBlockToRect(block, frame) + } + return Rect( + frame.left + block.charLefts[start] * frame.width, + frame.top + block.top * frame.height, + frame.left + block.charRights[end - 1] * frame.width, + frame.top + block.bottom * frame.height + ) +} + +/** Exact page-space bounds for an OCR-anchored markup. */ +internal fun PdfMarkup.textMarkupRangeRect( + blocks: List, + frame: Rect +): Rect? = when (this) { + is PdfMarkup.TextBlockHighlightMarkup -> blocks.firstOrNull { it.id == blockId }?.let { block -> + expandedTextHighlightRect( + ocrTextRangeToRect(block, OcrTextRange(blockId, start, end), frame) + ) + } + is PdfMarkup.TextBlockLineMarkup -> blocks.firstOrNull { it.id == blockId }?.let { block -> + ocrTextRangeToRect(block, OcrTextRange(blockId, start, end), frame) + } + else -> null +} + +/** Baseline-aware y-position used by both the on-screen renderer and PDF export. */ +internal fun PdfMarkup.textMarkupLineY(rangeRect: Rect): Float? = when (this) { + is PdfMarkup.TextBlockLineMarkup -> if (strikeThrough) { + rangeRect.center.y + } else { + // PdfTextService exposes the glyph baseline as `bottom`. A small gap keeps the + // underline below descenders instead of cutting through the glyphs, as the old + // `bottom - 10%` placement did. + rangeRect.bottom + (rangeRect.height * 0.08f).coerceIn(1.5f, 6f) + } + else -> null +} + +/** A forgiving touch target around a precise OCR markup, matching professional PDF tools. */ +internal fun PdfMarkup.textMarkupHitBounds( + blocks: List, + frame: Rect +): Rect? { + val rangeRect = textMarkupRangeRect(blocks, frame) ?: return null + return when (this) { + is PdfMarkup.TextBlockHighlightMarkup -> rangeRect.inflate(8f) + is PdfMarkup.TextBlockLineMarkup -> { + val y = textMarkupLineY(rangeRect) ?: return null + val verticalTouch = max(14f, width * 3f) + Rect( + rangeRect.left - 12f, + y - verticalTouch, + rangeRect.right + 12f, + y + verticalTouch + ) + } + else -> null + } +} + +/** Hit-testing overload for OCR markups; other annotations retain their existing behavior. */ +internal fun PdfMarkup.hitTest( + point: Offset, + blocks: List, + frame: Rect +): Boolean = when (this) { + is PdfMarkup.TextBlockHighlightMarkup, + is PdfMarkup.TextBlockLineMarkup -> textMarkupHitBounds(blocks, frame)?.contains(point) == true + else -> hitTest(point) +} + +internal fun expandedTextHighlightRect(rect: Rect, verticalScale: Float = 1f): Rect { + val padX = (rect.height * 0.05f).coerceIn(0.75f, 3f) + val padTop = rect.height * 0.13f * verticalScale + val padBottom = rect.height * 0.11f * verticalScale + return Rect(rect.left - padX, rect.top - padTop, rect.right + padX, rect.bottom + padBottom) +} + +internal fun ocrSelectionHandleAnchors( + blocks: List, + ranges: List, + frame: Rect +): Pair? { + if (ranges.isEmpty()) return null + val blocksById = blocks.associateBy { it.id } + val ordered = ranges.sortedWith( + compareBy( + { blocksById[it.blockId]?.top ?: Float.MAX_VALUE }, + { blocksById[it.blockId]?.left ?: Float.MAX_VALUE }, + { it.start } + ) + ) + val first = ordered.first() + val last = ordered.last() + val firstRect = expandedTextHighlightRect( + ocrTextRangeToRect(blocksById[first.blockId] ?: return null, first, frame), + verticalScale = 1.35f + ) + val lastRect = expandedTextHighlightRect( + ocrTextRangeToRect(blocksById[last.blockId] ?: return null, last, frame), + verticalScale = 1.35f + ) + return Offset(firstRect.left, firstRect.bottom) to Offset(lastRect.right, lastRect.bottom) +} + +/** Custom organic handle used by the PDF selection layer; deliberately not a platform handle. */ +internal fun DrawScope.drawTextSelectionHandle( + anchor: Offset, + diameter: Float, + color: Color = Color(0xFF4285F4) +) { + val neckHalfWidth = diameter * 0.22f + val bodyRadius = diameter * 0.47f + val top = anchor.y - 1f + val bottom = top + bodyRadius * 2f + val path = Path().apply { + moveTo(anchor.x - neckHalfWidth, top) + lineTo(anchor.x + neckHalfWidth, top) + lineTo(anchor.x + neckHalfWidth, top + 6f) + cubicTo(anchor.x + bodyRadius, top + 9f, anchor.x + bodyRadius, bottom - 2f, anchor.x, bottom) + cubicTo(anchor.x - bodyRadius, bottom - 2f, anchor.x - bodyRadius, top + 9f, anchor.x - neckHalfWidth, top + 6f) + close() + } + drawPath(path, color) +} + +internal fun OcrTextBlock.wordRangeAtPoint(point: Offset, frame: Rect): OcrTextRange? { + val blockRect = ocrBlockToRect(this, frame) + if (!Rect(blockRect.left - 6f, blockRect.top - 6f, blockRect.right + 6f, blockRect.bottom + 6f).contains(point)) return null + val word = wordRanges().minByOrNull { word -> + val wordRect = ocrTextRangeToRect(this, OcrTextRange(id, word.first, word.last + 1), frame) + val dx = when { + point.x < wordRect.left -> wordRect.left - point.x + point.x > wordRect.right -> point.x - wordRect.right + else -> 0f + } + val dy = when { + point.y < wordRect.top -> wordRect.top - point.y + point.y > wordRect.bottom -> point.y - wordRect.bottom + else -> 0f + } + dx * dx + dy * dy + } ?: return null + return OcrTextRange(id, word.first, word.last + 1) +} + +internal fun hitTestOcrBlock(blocks: List, contentPoint: Offset, frame: Rect): OcrTextBlock? { + return blocks.firstOrNull { b -> + val r = ocrBlockToRect(b, frame) + val expanded = Rect(r.left - 4f, r.top - 4f, r.right + 4f, r.bottom + 4f) + expanded.contains(contentPoint) + } +} + +internal fun hitTestOcrWord(blocks: List, contentPoint: Offset, frame: Rect): OcrTextRange? = + blocks.firstNotNullOfOrNull { it.wordRangeAtPoint(contentPoint, frame) } + +internal fun intersects(a: Rect, b: Rect): Boolean = + a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top + +/** + * The contiguous run of words between two content points, **in reading order** β€” a real text + * selection rather than a rectangular marquee. Words are ordered top-to-bottom and left-to-right + * within a line (lines grouped by vertical overlap); each point snaps to its nearest word, and every + * word from the earlier position to the later one is returned. This is what lets a drag select a + * whole sentence or paragraph the way selecting text on a page should, instead of one word at a time. + */ +internal fun ocrRangeBetween( + blocks: List, + frame: Rect, + p1: Offset, + p2: Offset +): Set { + if (blocks.isEmpty()) return emptySet() + val ordered = blocks.readingOrder() + val i1 = ordered.nearestBlockIndex(frame, p1) + val i2 = ordered.nearestBlockIndex(frame, p2) + if (i1 < 0 || i2 < 0) return emptySet() + val lo = minOf(i1, i2) + val hi = maxOf(i1, i2) + return ordered.subList(lo, hi + 1).map { it.id }.toSet() +} + +/** Returns a contiguous word selection, grouped back into precise ranges per PDF text line. */ +internal fun ocrWordRangesBetween( + blocks: List, + frame: Rect, + p1: Offset, + p2: Offset +): List { + if (blocks.isEmpty()) return emptyList() + val words = blocks.readingOrder().flatMap { block -> + block.wordRanges().map { word -> + val range = OcrTextRange(block.id, word.first, word.last + 1) + OcrWordHit(range, ocrTextRangeToRect(block, range, frame)) + } + } + if (words.isEmpty()) return emptyList() + val i1 = words.nearestWordIndex(p1) + val i2 = words.nearestWordIndex(p2) + val lo = minOf(i1, i2) + val hi = maxOf(i1, i2) + return words.subList(lo, hi + 1) + .groupBy { it.range.blockId } + .values + .map { group -> + OcrTextRange( + blockId = group.first().range.blockId, + start = group.minOf { it.range.start }, + end = group.maxOf { it.range.end } + ) + } +} + +private data class OcrWordHit(val range: OcrTextRange, val rect: Rect) + +private fun List.nearestWordIndex(point: Offset): Int = + indices.minByOrNull { index -> + val rect = this[index].rect + val dx = when { + point.x < rect.left -> rect.left - point.x + point.x > rect.right -> point.x - rect.right + else -> 0f + } + val dy = when { + point.y < rect.top -> rect.top - point.y + point.y > rect.bottom -> point.y - rect.bottom + else -> 0f + } + dx * dx + dy * dy + } ?: 0 + +/** Words sorted into reading order: grouped into lines by vertical overlap, then left-to-right. */ +private fun List.readingOrder(): List { + if (isEmpty()) return this + val avgH = map { it.bottom - it.top }.average().toFloat().coerceAtLeast(0.001f) + val gap = avgH * 0.6f + val lines = mutableListOf>() + for (b in sortedBy { it.top }) { + val last = lines.lastOrNull() + val cy = (b.top + b.bottom) / 2f + val lastCy = last?.first()?.let { (it.top + it.bottom) / 2f } + if (last == null || lastCy == null || cy - lastCy > gap) lines.add(mutableListOf(b)) + else last.add(b) + } + return lines.flatMap { line -> line.sortedBy { it.left } } +} + +/** Index of the word containing [p], else the nearest word by centre distance. -1 if empty. */ +private fun List.nearestBlockIndex(frame: Rect, p: Offset): Int { + forEachIndexed { i, b -> if (ocrBlockToRect(b, frame).contains(p)) return i } + var best = -1 + var bestD = Float.MAX_VALUE + forEachIndexed { i, b -> + val r = ocrBlockToRect(b, frame) + val dx = (r.left + r.right) / 2f - p.x + val dy = (r.top + r.bottom) / 2f - p.y + val d = dx * dx + dy * dy + if (d < bestD) { bestD = d; best = i } + } + return best +} + +internal fun distToSegment(p: Offset, a: Offset, b: Offset): Float { + val l2 = (b - a).getDistanceSq() + if (l2 == 0f) return (p - a).getDistance() + val t = (((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2).coerceIn(0f, 1f) + val proj = Offset(a.x + t * (b.x - a.x), a.y + t * (b.y - a.y)) + return (p - proj).getDistance() +} + +internal fun Offset.getDistanceSq(): Float = x * x + y * y + +internal fun smoothPath(pts: List): Path { + val path = Path() + if (pts.isEmpty()) return path + path.moveTo(pts[0].x, pts[0].y) + if (pts.size == 1) return path + if (pts.size == 2) { + path.lineTo(pts[1].x, pts[1].y) + return path + } + for (i in 1 until pts.size - 1) { + val p0 = pts[i] + val p1 = pts[i + 1] + val midX = (p0.x + p1.x) / 2f + val midY = (p0.y + p1.y) / 2f + path.quadraticTo(p0.x, p0.y, midX, midY) + } + path.lineTo(pts.last().x, pts.last().y) + return path +} + +internal fun DrawScope.drawArrow( + start: Offset, end: Offset, color: Color, width: Float +) { + drawLine(color, start, end, width, cap = StrokeCap.Round) + val angle = atan2((end.y - start.y).toDouble(), (end.x - start.x).toDouble()) + val arrowLen = (width * 3.5f).coerceAtLeast(18f) + val angle1 = angle + PI - (PI / 6) + val angle2 = angle + PI + (PI / 6) + val p1 = Offset((end.x + arrowLen * cos(angle1)).toFloat(), (end.y + arrowLen * sin(angle1)).toFloat()) + val p2 = Offset((end.x + arrowLen * cos(angle2)).toFloat(), (end.y + arrowLen * sin(angle2)).toFloat()) + val path = Path().apply { + moveTo(end.x, end.y) + lineTo(p1.x, p1.y) + lineTo(p2.x, p2.y) + close() + } + drawPath(path, color) +} + +internal fun buildExportOverlays( + annotationsByPage: Map>, + ocrBlocksByPage: Map>, + pageCanvasSizes: Map, + pageBitmapSizes: Map +): Map> { + val map = mutableMapOf>() + + annotationsByPage.forEach { (page, markups) -> + if (markups.isEmpty()) return@forEach + val cs = pageCanvasSizes[page] ?: return@forEach + val bs = pageBitmapSizes[page] ?: cs + if (cs.width <= 0f || cs.height <= 0f || bs.width <= 0f || bs.height <= 0f) return@forEach + + val frame = fitBitmapRect(cs, bs.width, bs.height) + + fun normPoint(p: Offset): NormalizedPoint = NormalizedPoint( + x = ((p.x - frame.left) / frame.width).coerceIn(0f, 1f), + y = ((p.y - frame.top) / frame.height).coerceIn(0f, 1f) + ) + + fun normDist(px: Float): Float = px / frame.width.coerceAtLeast(1f) + + val ocrBlocks = ocrBlocksByPage[page].orEmpty() + val list = mutableListOf() + + markups.forEach { markup -> + when (markup) { + is PdfMarkup.StrokeMarkup -> { + if (markup.points.size > 1) { + list.add( + ExportOverlay.Stroke( + points = markup.points.map { normPoint(it) }, + colorArgb = markup.color.toArgb(), + widthNorm = normDist(markup.width), + alpha = markup.alpha + ) + ) + } + } + is PdfMarkup.RectMarkup -> { + list.add( + ExportOverlay.RectShape( + start = normPoint(markup.start), + end = normPoint(markup.end), + colorArgb = markup.color.toArgb(), + alpha = markup.alpha, + filled = markup.filled + ) + ) + } + is PdfMarkup.OvalMarkup -> { + list.add( + ExportOverlay.OvalShape( + start = normPoint(markup.start), + end = normPoint(markup.end), + colorArgb = markup.color.toArgb(), + alpha = markup.alpha, + filled = markup.filled + ) + ) + } + is PdfMarkup.LineMarkup -> { + list.add( + ExportOverlay.LineShape( + start = normPoint(markup.start), + end = normPoint(markup.end), + colorArgb = markup.color.toArgb(), + widthNorm = normDist(markup.width), + alpha = markup.alpha, + arrowHead = markup.arrowHead + ) + ) + } + is PdfMarkup.TextBlockHighlightMarkup -> { + ocrBlocks.firstOrNull { it.id == markup.blockId }?.let { b -> + val range = OcrTextRange(markup.blockId, markup.start, markup.end) + val r = ocrTextRangeToRect(b, range, Rect(0f, 0f, 1f, 1f)) + list.add( + ExportOverlay.RectShape( + start = NormalizedPoint(r.left, r.top), + end = NormalizedPoint(r.right, r.bottom), + colorArgb = markup.color.toArgb(), + alpha = markup.alpha, + filled = true + ) + ) + } + } + is PdfMarkup.TextBlockLineMarkup -> { + ocrBlocks.firstOrNull { it.id == markup.blockId }?.let { b -> + val range = OcrTextRange(markup.blockId, markup.start, markup.end) + val r = ocrTextRangeToRect(b, range, Rect(0f, 0f, 1f, 1f)) + val y = markup.textMarkupLineY(r) ?: return@let + list.add( + ExportOverlay.LineShape( + start = NormalizedPoint(r.left, y), + end = NormalizedPoint(r.right, y), + colorArgb = markup.color.toArgb(), + widthNorm = normDist(markup.width), + alpha = markup.alpha, + arrowHead = false + ) + ) + } + } + is PdfMarkup.ImageMarkup -> { + if (!markup.bitmap.isRecycled) { + list.add( + ExportOverlay.ImageStamp( + bitmap = markup.bitmap, + start = normPoint(markup.start), + end = normPoint(markup.end) + ) + ) + } + } + is PdfMarkup.TextBoxMarkup -> { + if (markup.text.isNotBlank()) { + list.add( + ExportOverlay.TextStamp( + position = normPoint(markup.position), + text = markup.text, + colorArgb = markup.color.toArgb(), + fontSizeNorm = (markup.fontSize / frame.height.coerceAtLeast(1f)) + ) + ) + } + } + is PdfMarkup.NoteMarkup -> { + list.add( + ExportOverlay.NoteStamp( + position = normPoint(markup.anchor), + text = markup.text, + colorArgb = markup.color.toArgb() + ) + ) + } + } + } + + if (list.isNotEmpty()) map[page] = list + } + + return map +} + +internal fun recolorSignatureBitmap(source: Bitmap, colorArgb: Int): Bitmap { + if (source.isRecycled || source.width <= 0 || source.height <= 0) return source + + val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888) + val pixels = IntArray(source.width * source.height) + source.getPixels(pixels, 0, source.width, 0, 0, source.width, source.height) + val rgb = colorArgb and 0x00FFFFFF + for (index in pixels.indices) { + val alpha = android.graphics.Color.alpha(pixels[index]) + pixels[index] = if (alpha == 0) 0 else (alpha shl 24) or rgb + } + result.setPixels(pixels, 0, source.width, 0, 0, source.width, source.height) + return result +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt index 99f89fd..0a19264 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt @@ -1,93 +1,76 @@ package com.chethan616.clearpdf.ui.screen -import androidx.compose.ui.res.stringResource -import com.chethan616.clearpdf.R - import android.app.Activity import android.graphics.Bitmap import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDecay +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.togetherWith -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculateCentroid -import androidx.compose.foundation.gestures.calculateCentroidSize import androidx.compose.foundation.gestures.calculatePan import androidx.compose.foundation.gestures.calculateZoom -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.ui.geometry.isSpecified import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.horizontalScroll +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset 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.layout.imePadding -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.union +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.VerticalPager -import androidx.compose.material.icons.rounded.FormatListNumbered -import androidx.compose.material.icons.rounded.SwapHoriz -import androidx.compose.material.icons.rounded.SwapVert -import kotlin.math.roundToInt -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.ui.window.Dialog -import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.distinctUntilChanged import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowBackIosNew import androidx.compose.material.icons.rounded.PictureAsPdf -import androidx.compose.material.icons.rounded.UploadFile import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Gesture -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.KeyboardArrowUp -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.rounded.UploadFile import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -106,84 +89,63 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.util.VelocityTracker -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import com.chethan616.clearpdf.ui.components.CloseCrossIcon +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.data.repository.AppSettingsManager +import com.chethan616.clearpdf.ui.components.DecryptingAnimation import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.GlassTitlePill import com.chethan616.clearpdf.ui.components.LiquidIconButton -import com.chethan616.clearpdf.ui.components.LiquidSaveDialog +import com.chethan616.clearpdf.ui.components.ViewerChromeGlass +import com.chethan616.clearpdf.ui.components.LiquidSaveSheet import com.chethan616.clearpdf.ui.components.liquidGlassPanel -import androidx.compose.material.icons.rounded.Close +import com.chethan616.clearpdf.ui.components.viewerChromeGlass +import com.chethan616.clearpdf.ui.components.viewerGlass import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode -import com.chethan616.clearpdf.ui.viewmodel.ExportOverlay -import com.chethan616.clearpdf.ui.viewmodel.FindMatch -import com.chethan616.clearpdf.ui.viewmodel.NormalizedPoint import com.chethan616.clearpdf.ui.utils.rememberUISensor -import com.chethan616.clearpdf.ui.viewmodel.OcrTextBlock import com.chethan616.clearpdf.ui.viewmodel.PdfViewerViewModel +import com.chethan616.clearpdf.ui.viewmodel.OcrTextRange import com.kyant.backdrop.backdrops.LayerBackdrop -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.input.TextFieldValue +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.math.PI -import kotlin.math.abs -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.max -import kotlin.math.min -import kotlin.math.pow -import kotlin.math.sin - -private const val MIN_ZOOM = 1.0f -private const val MAX_ZOOM = 8.0f -private const val ZOOM_SLOP = 0.01f -private const val PAN_SLOP_PX = 8f -private const val SWIPE_ANGLE_DEG = 38f -private const val SNAP_BACK_THRESHOLD = 1.08f -private const val FLING_FRICTION = 0.92f -private const val FLING_MIN_VELOCITY = 50f -private const val DOUBLE_TAP_ZOOM_1 = 2.5f -private const val DOUBLE_TAP_ZOOM_2 = 4.5f +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt @Composable fun PdfViewerScreen( backdrop: LayerBackdrop, viewModel: PdfViewerViewModel, - onBack: () -> Unit + onBack: () -> Unit, + // True when the caller (recents / external open / a tool's output) already handed us a document + // to load. In that case the viewer must NOT flash its "Open a PDF" picker while the pages render β€” + // it shows a loading curtain that the real document fades in behind. See [ViewerLoadingCurtain]. + pendingLoad: Boolean = false ) { val state by viewModel.uiState.collectAsState() val isDarkMode = LocalIsDarkMode.current @@ -199,52 +161,195 @@ fun PdfViewerScreen( val view = LocalView.current val scope = rememberCoroutineScope() + // ── Local UI state ───────────────────────────────────────────────────── var controlsVisible by rememberSaveable { mutableStateOf(true) } var controlsPinned by rememberSaveable { mutableStateOf(false) } + // True while the bottom "Editor Tools" panel is expanded β€” keeps the chrome from auto-hiding + // so the user can browse tools without it disappearing. + var editorToolsOpen by rememberSaveable { mutableStateOf(false) } + // True for as long as the share capsule is held morphed open. Not saveable on purpose: a + // configuration change cancels the pointer gesture, so a persisted `true` would pin the chrome + // open forever with no finger left to release it. + var shareHolding by remember { mutableStateOf(false) } var lastInteractionAtMs by rememberSaveable { mutableStateOf(System.currentTimeMillis()) } - var scrollOrientation by rememberSaveable { mutableStateOf(if (com.chethan616.clearpdf.data.repository.AppSettingsManager.getScrollOrientation(context) == 1) ScrollOrientation.Horizontal else ScrollOrientation.Vertical) } + // Adobe-style single-axis reading: the viewer is vertical-only. Horizontal + // panning is still allowed inside a zoomed page (handled in PdfContinuousPage). + val scrollOrientation = ScrollOrientation.Vertical var showPageJumpDialog by rememberSaveable { mutableStateOf(false) } + var activeTool by rememberSaveable { mutableStateOf(PdfEditTool.None) } + var currentColorLong by rememberSaveable { mutableLongStateOf(0xFF00BCD4L) } + val currentColor = Color(currentColorLong) + var currentStrokeWidth by rememberSaveable { mutableFloatStateOf(6f) } + var activeImageId by remember { mutableStateOf(null) } + var showSaveDialog by rememberSaveable { mutableStateOf(false) } + var showShareDialog by remember { mutableStateOf(false) } + var showFindBar by rememberSaveable { mutableStateOf(false) } + var findQuery by rememberSaveable { mutableStateOf("") } + val findFocusRequester = remember { FocusRequester() } + var passwordText by rememberSaveable { mutableStateOf("") } + val passwordFocusRequester = remember { FocusRequester() } + var showSignaturePad by remember { mutableStateOf(false) } + var showZoomHud by remember { mutableStateOf(false) } + // Annotation (text box / sticky note) editing + var editingAnnoId by remember { mutableStateOf(null) } + var editingAnnoPage by remember { mutableStateOf(0) } + var editingAnnoIsNote by remember { mutableStateOf(false) } + var annotationDraft by remember { mutableStateOf("") } + var editingAnnoColor by remember { mutableStateOf(Color(0xFF1976D2)) } + // Shape (rect / oval / line / arrow / stroke) editing β€” identified by page + list index. + var editingShapePage by remember { mutableStateOf(null) } + var editingShapeIndex by remember { mutableStateOf(-1) } + // Generic markup selection (move + resize of shapes / text / notes). + var selectedAnnoPage by remember { mutableStateOf(null) } + var selectedAnnoIndex by remember { mutableStateOf(-1) } + val density = LocalDensity.current + + // ── Zoom / pan state ─────────────────────────────────────────────────── + // Ported from Pdf_Tools (Karna14314): document-level zoom/pan is plain float + // state updated *synchronously* inside the gesture loop. The earlier + // Animatable approach launched a coroutine (snapTo) per pointer event, which + // raced on single-page docs and made pinch/pan stutter β€” see settings credit. + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(scale) { + if (scale > 1.05f) { showZoomHud = true; delay(900); showZoomHud = false } + else showZoomHud = false + } - val zoomAnim = remember { Animatable(1f) } - val panXAnim = remember { Animatable(0f) } - val panYAnim = remember { Animatable(0f) } + // ── Page annotation state ────────────────────────────────────────────── + val annotationsByPage = remember { mutableStateMapOf>() } + val pageCanvasSizes = remember { mutableStateMapOf() } + val pageBitmapSizes = remember { mutableStateMapOf() } - val zoomScale: Float = zoomAnim.value - val panOffset: Offset = Offset(panXAnim.value, panYAnim.value) + fun getPageMarks(page: Int): MutableList = + annotationsByPage.getOrPut(page) { mutableStateListOf() } - var showZoomHud by remember { mutableStateOf(false) } - val zoomHudText = "${(zoomAnim.value * 100 + 0.5f).toInt()}%" + fun selectedTextRanges(page: Int): List = + state.selectedOcrRangesByPage[page].orEmpty() + + // Undo history: the page each added markup landed on, newest last. Undo pops the most recent + // entry and removes THAT page's last mark, so "undo" means the last thing the user actually + // did. The old behaviour removed the last mark of `listState.firstVisibleItemIndex`, which is + // the first *partially* visible page β€” usually a sliver of the previous page while you draw on + // the one filling the screen, so undo silently no-op'd on an empty list. The same trap is + // already documented for image placement at `activeImageLoc`. + val undoStack = remember { mutableStateListOf() } + + // Declared here (rather than lower) so the image/signature launchers below can place + // annotations onto whichever page is under the viewport centre. + val listState = rememberLazyListState() + + // iOS-style momentum for the continuous page scroll: a lower-friction exponential decay + // glides longer and settles smoothly (vs the stiffer platform spline), so even 2–3 page + // docs feel continuous instead of stopping abruptly. Only the fling curve changes β€” the + // scroll mechanics and the default stretch overscroll (rubber-band at the edges) are intact. + val iosPageDecay = remember { androidx.compose.animation.core.exponentialDecay(frictionMultiplier = 0.55f) } + val pageFling = remember(iosPageDecay) { + object : androidx.compose.foundation.gestures.FlingBehavior { + override suspend fun androidx.compose.foundation.gestures.ScrollScope.performFling(initialVelocity: Float): Float { + if (kotlin.math.abs(initialVelocity) <= 1f) return initialVelocity + var lastValue = 0f + var velocityLeft = initialVelocity + androidx.compose.animation.core.AnimationState(initialValue = 0f, initialVelocity = initialVelocity) + .animateDecay(iosPageDecay) { + val delta = value - lastValue + val consumed = scrollBy(delta) + lastValue = value + velocityLeft = this.velocity + // Hit an edge / content end β†’ stop so overscroll can take over. + if (kotlin.math.abs(delta - consumed) > 0.5f) cancelAnimation() + } + return velocityLeft + } + } + } - LaunchedEffect(zoomAnim.value) { - if (zoomAnim.value > 1.05f) { - showZoomHud = true - delay(900) - showZoomHud = false - } else { - showZoomHud = false + // The page + on-screen position where a newly placed image/signature should land: the + // page occupying the vertical centre of the viewport, at the point the user is looking + // at (so a sign never silently lands off-screen on page 1). Returns a null offset when + // the page isn't measured yet β†’ callers fall back to that page's frame centre. + fun viewportPlacementTarget(): Pair { + val info = listState.layoutInfo + val visible = info.visibleItemsInfo + if (visible.isEmpty()) return state.currentPage to null + val centerY = (info.viewportStartOffset + info.viewportEndOffset) / 2 + val item = visible.firstOrNull { centerY >= it.offset && centerY < it.offset + it.size } + ?: visible.minByOrNull { kotlin.math.abs((it.offset + it.size / 2) - centerY) }!! + val page = item.index + val cs = pageCanvasSizes[page] ?: return page to null + if (cs.width < 50f || cs.height < 50f) return page to null + val topPadPx = with(density) { 6.dp.toPx() } + val localY = (centerY - item.offset - topPadPx).coerceIn(0f, cs.height) + val bs = pageBitmapSizes[page] ?: cs + val frame = fitBitmapRect(cs, bs.width, bs.height) + return page to Offset(frame.center.x, localY) + } + + /** Record that [page] just gained a markup, so undo can find it again. */ + fun recordEdit(page: Int) { undoStack.add(page) } + + /** + * Remove the most recently added markup, wherever it lives. Entries can go stale β€” the eraser + * and the shape editor delete marks without touching the stack β€” so pop past any page that has + * since been emptied. If the history is exhausted (or was never populated) fall back to the page + * under the viewport centre, which is the page the user is looking at. + */ + fun undoLastEdit() { + while (undoStack.isNotEmpty()) { + val page = undoStack.removeAt(undoStack.lastIndex) + val marks = getPageMarks(page) + if (marks.isNotEmpty()) { marks.removeAt(marks.lastIndex); return } } + val marks = getPageMarks(viewportPlacementTarget().first) + if (marks.isNotEmpty()) marks.removeAt(marks.lastIndex) } - var activeTool by rememberSaveable { mutableStateOf(PdfEditTool.None) } - var currentColorLong by rememberSaveable { mutableLongStateOf(0xFF00BCD4L) } - val currentColor = Color(currentColorLong) - var currentStrokeWidth by rememberSaveable { mutableFloatStateOf(6f) } - var activeImageId by remember { mutableStateOf(null) } - var showSaveDialog by rememberSaveable { mutableStateOf(false) } - var showFindBar by rememberSaveable { mutableStateOf(false) } - var findQuery by rememberSaveable { mutableStateOf("") } - val findFocusRequester = remember { FocusRequester() } - var passwordText by rememberSaveable { mutableStateOf("") } - val passwordFocusRequester = remember { FocusRequester() } - var showSignaturePad by remember { mutableStateOf(false) } - val haptic = LocalHapticFeedback.current + /** Clear every markup on the page under the viewport centre β€” the one the user can see. */ + fun clearVisiblePage() { + val page = viewportPlacementTarget().first + getPageMarks(page).clear() + undoStack.removeAll { it == page } + } + + // Add a new image/signature centred at the viewport-target on the correct page, sized + // to the page and clamped fully on-page. Shared by the image picker and signature pad. + fun placeImageMarkup(bmp: Bitmap, isSignature: Boolean) { + val (page, target) = viewportPlacementTarget() + val marks = getPageMarks(page) + val rawCs = pageCanvasSizes[page] + val cs = if (rawCs != null && rawCs.width > 50f && rawCs.height > 50f) rawCs else Size(1000f, 1400f) + val bs = pageBitmapSizes[page] ?: cs + val frame = fitBitmapRect(cs, bs.width, bs.height) + val maxW = (if (frame.width > 50f) frame.width else cs.width) * (if (isSignature) 0.45f else 0.5f) + val ratio = bmp.height.toFloat() / bmp.width.toFloat().coerceAtLeast(1f) + val w = maxW.coerceAtLeast(100f) + val h = w * ratio + val fallbackCenter = if (frame.width > 50f) frame.center else Offset(cs.width / 2f, cs.height / 2f) + val center = target ?: fallbackCenter + val cx = center.x.coerceIn(w / 2f, (cs.width - w / 2f).coerceAtLeast(w / 2f)) + val cy = center.y.coerceIn(h / 2f, (cs.height - h / 2f).coerceAtLeast(h / 2f)) + val id = System.nanoTime() + marks.add(PdfMarkup.ImageMarkup(id, bmp, Offset(cx - w / 2f, cy - h / 2f), Offset(cx + w / 2f, cy + h / 2f), isSignature)) + recordEdit(page) + activeImageId = id; activeTool = PdfEditTool.Image; controlsVisible = true + } - val annotationsByPage = remember { mutableStateMapOf>() } - val pageCanvasSizes = remember { mutableStateMapOf() } - val pageBitmapSizes = remember { mutableStateMapOf() } + // Locate the currently selected image across ALL pages (it may live on a page other + // than firstVisibleItemIndex, since placement targets the viewport-centre page). + fun activeImageLoc(): Triple? { + val id = activeImageId ?: return null + annotationsByPage.forEach { (pg, m) -> + val i = m.indexOfLast { it is PdfMarkup.ImageMarkup && it.id == id } + if (i >= 0) return Triple(pg, i, m[i] as PdfMarkup.ImageMarkup) + } + return null + } - fun getPageMarks(page: Int): MutableList = - annotationsByPage.getOrPut(page) { mutableStateListOf() } + // ── Launchers ───────────────────────────────────────────────────────── + val pdfPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { viewModel.openPdf(context, it) } + } val imagePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri == null) return@rememberLauncherForActivityResult @@ -253,66 +358,23 @@ fun PdfViewerScreen( android.graphics.BitmapFactory.decodeStream(it) } }.getOrNull() ?: return@rememberLauncherForActivityResult - val page = state.currentPage - val marks = getPageMarks(page) - val existingIdx = marks.indexOfLast { it is PdfMarkup.ImageMarkup && it.id == activeImageId } - if (existingIdx >= 0) { + // If an image is currently selected, replace its bitmap in place; otherwise place a + // new one on the page under the viewport (never silently on page 1). + val existingPage = annotationsByPage.entries.firstOrNull { (_, m) -> m.any { it is PdfMarkup.ImageMarkup && it.id == activeImageId } }?.key + val existingIdx = existingPage?.let { getPageMarks(it).indexOfLast { m -> m is PdfMarkup.ImageMarkup && m.id == activeImageId } } ?: -1 + if (existingPage != null && existingIdx >= 0) { + val marks = getPageMarks(existingPage) val existing = marks[existingIdx] as PdfMarkup.ImageMarkup val ratio = bmp.height.toFloat() / bmp.width.toFloat().coerceAtLeast(1f) - val currentWidth = (existing.end.x - existing.start.x).let { kotlin.math.abs(it) }.coerceAtLeast(20f) - val newHeight = currentWidth * ratio - marks[existingIdx] = existing.copy( - bitmap = bmp, - end = Offset(existing.end.x, existing.start.y + newHeight), - isSignature = false - ) + val currentW = kotlin.math.abs(existing.end.x - existing.start.x).coerceAtLeast(20f) + marks[existingIdx] = existing.copy(bitmap = bmp, end = Offset(existing.end.x, existing.start.y + currentW * ratio), isSignature = false) } else { - val cs = pageCanvasSizes[page] ?: Size(bmp.width.toFloat(), bmp.height.toFloat()) - val frame = fitBitmapRect(cs, (pageBitmapSizes[page]?.width ?: cs.width), (pageBitmapSizes[page]?.height ?: cs.height)) - val maxW = (if (frame.width > 0f) frame.width else cs.width) * 0.5f - val ratio = bmp.height.toFloat() / bmp.width.toFloat().coerceAtLeast(1f) - val w = maxW.coerceAtLeast(1f) - val h = w * ratio - val center = if (frame.width > 0f) frame.center else Offset(cs.width / 2f, cs.height / 2f) - val id = System.nanoTime() - marks.add( - PdfMarkup.ImageMarkup( - id = id, - bitmap = bmp, - start = Offset(center.x - w / 2f, center.y - h / 2f), - end = Offset(center.x + w / 2f, center.y + h / 2f), - isSignature = false - ) - ) - activeImageId = id - activeTool = PdfEditTool.Image - } - } - - suspend fun animateZoomPan( - targetZoom: Float, - targetPan: Offset, - dampingRatio: Float = Spring.DampingRatioMediumBouncy, - stiffness: Float = Spring.StiffnessMedium - ) { - val spec = spring(dampingRatio = dampingRatio, stiffness = stiffness) - coroutineScope { - launch { zoomAnim.animateTo(targetZoom, spec) } - launch { panXAnim.animateTo(targetPan.x, spec) } - launch { panYAnim.animateTo(targetPan.y, spec) } - } - } - - suspend fun snapZoomPan(targetZoom: Float, targetPan: Offset) { - coroutineScope { - launch { zoomAnim.snapTo(targetZoom) } - launch { panXAnim.snapTo(targetPan.x) } - launch { panYAnim.snapTo(targetPan.y) } + placeImageMarkup(bmp, isSignature = false) } } + // ── Immersive mode ──────────────────────────────────────────────────── val immersiveActive = state.document != null && !controlsVisible - DisposableEffect(activity, view, immersiveActive) { val ctrl = activity?.let { WindowCompat.getInsetsController(it.window, view) } ctrl?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE @@ -321,164 +383,168 @@ fun PdfViewerScreen( onDispose { ctrl?.show(WindowInsetsCompat.Type.systemBars()) } } + // ── Document lifecycle effects ───────────────────────────────────────── LaunchedEffect(state.document?.uri) { - controlsVisible = true - controlsPinned = false + controlsVisible = true; controlsPinned = false lastInteractionAtMs = System.currentTimeMillis() - zoomAnim.snapTo(1f); panXAnim.snapTo(0f); panYAnim.snapTo(0f) + scale = 1f; offsetX = 0f activeTool = PdfEditTool.None - annotationsByPage.clear(); pageCanvasSizes.clear(); pageBitmapSizes.clear() - viewModel.clearOcrSelection(state.currentPage) - viewModel.clearExportFeedback() - showFindBar = false - findQuery = "" - viewModel.clearSearch() + selectedAnnoPage = null; selectedAnnoIndex = -1 + annotationsByPage.clear(); undoStack.clear(); pageCanvasSizes.clear(); pageBitmapSizes.clear() + viewModel.clearOcrSelection(state.currentPage); viewModel.clearExportFeedback() + showFindBar = false; findQuery = ""; viewModel.clearSearch() } - val pagerScopeForFind = rememberCoroutineScope() - - LaunchedEffect(state.document, controlsVisible, controlsPinned, activeTool, lastInteractionAtMs) { - if (state.document != null && controlsVisible && !controlsPinned - && zoomAnim.value <= 1.01f && activeTool == PdfEditTool.None - ) { + LaunchedEffect(state.document, controlsVisible, controlsPinned, activeTool, editorToolsOpen, shareHolding, lastInteractionAtMs) { + if (state.document != null && controlsVisible && !controlsPinned && scale <= 1.01f && activeTool == PdfEditTool.None && !editorToolsOpen && !shareHolding) { val snap = lastInteractionAtMs - delay(3500) - if (controlsVisible && !controlsPinned && zoomAnim.value <= 1.01f - && activeTool == PdfEditTool.None && snap == lastInteractionAtMs) + // Comfortable auto-hide window; any interaction bumps lastInteractionAtMs and + // restarts this. It never fires while a tool is active (activeTool != None), while + // the Editor Tools panel is open, or while the share capsule is held β€” a long-press + // produces no pointer events for the viewer to see, so without that last guard the + // chrome hid itself out from under the finger mid-gesture. + delay(5000) + if (controlsVisible && !controlsPinned && scale <= 1.01f && activeTool == PdfEditTool.None && !editorToolsOpen && !shareHolding && snap == lastInteractionAtMs) controlsVisible = false } } - BackHandler(enabled = state.document != null) { - if (!controlsVisible) controlsVisible = true else onBack() - } - - val pdfPickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let { viewModel.openPdf(context, it) } + LaunchedEffect(state.passwordUri) { + if (state.passwordUri != null) { passwordText = ""; delay(120); passwordFocusRequester.requestFocus() } } - LaunchedEffect(state.passwordUri) { - if (state.passwordUri != null) { - passwordText = "" - delay(120) - passwordFocusRequester.requestFocus() - } + BackHandler(enabled = state.document != null) { + if (!controlsVisible) controlsVisible = true else onBack() } + // ── No-document state ───────────────────────────────────────────────── if (state.document == null) { - Column( - Modifier.fillMaxSize().statusBarsPadding().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) + val askingPassword = state.passwordRequired && state.passwordUri != null + // A document is on its way in (recents / external / tool output) and nothing has gone wrong + // yet: show the loading curtain instead of the picker so the user never sees the "Open a PDF" + // card flash before their file appears. The identical curtain is held over the loaded viewer + // below until the first page is rendered, so the swap between these two branches is seamless. + val openingHandedDoc = !askingPassword && state.errorMessage == null && (state.isLoading || pendingLoad) + if (openingHandedDoc) { + Box(Modifier.fillMaxSize()) { + ViewerLoadingCurtain(isLight = isLight) + // While a password PDF is actually being unlocked, play the padlock "decrypting" + // animation over the fill (styled after the onboarding page-5 demos). Plain opening + // fills (a normal load) show nothing extra β€” a lock would be misleading there. + if (state.decrypting) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + DecryptingAnimation(backdrop = backdrop) + } } - LiquidGlassTopBar( - stringResource(R.string.viewer_title), - backdrop, - uiSensor, - Modifier.weight(1f) - ) } + return + } + // The password prompt is an OVERLAY on this Box, not a third row inside the Column below. + // + // It used to be a sibling of the "Open a PDF" card in a `spacedBy(16.dp)` Column whose middle + // section carried `weight(1f)`. So the moment the prompt and the keyboard were both up, the + // weighted section was squeezed and the card was chopped mid-sentence β€” the whole point of a + // password dialog is that the thing behind it stays intact. + Box(Modifier.fillMaxSize()) { Column( - Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()), + Modifier.fillMaxSize().statusBarsPadding().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Same trio as the loaded viewer's chrome: back circle Β· centered title pill Β· balancer. + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + // No `surfaceColor`, like Home's β€” this circle used to paint a white 8% wash that + // nothing else in the app does. + LiquidIconButton(onClick = onBack, backdrop = backdrop) { + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(16.dp), text) + } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + GlassTitlePill(stringResource(R.string.viewer_title), backdrop) + } + Spacer(Modifier.size(40.dp)) + } Column( - Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(28.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically) ) { - Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(56.dp), accent) - BasicText( - stringResource(R.string.viewer_open_pdf), - style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold) - ) - BasicText( - stringResource(R.string.viewer_select_file), - style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center) - ) - LiquidButton( - onClick = { pdfPickerLauncher.launch(arrayOf("*/*")) }, - backdrop = backdrop, tint = accent + Column( + Modifier.fillMaxWidth().viewerGlass(backdrop, viewerChromeGlass(isDarkMode)).padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) - BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(56.dp), accent) + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.viewer_select_file), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + LiquidButton(onClick = { pdfPickerLauncher.launch(arrayOf("*/*")) }, backdrop = backdrop, tint = accent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } } } + state.errorMessage?.let { BasicText(it, style = TextStyle(Color(0xFFD32F2F), 14.sp)) } } - state.errorMessage?.let { BasicText(it, style = TextStyle(Color(0xFFD32F2F), 14.sp)) } + } + + // ── Password prompt (in-window, so the glass samples the real backdrop) ── + // `askingPassword` is computed at the top of this branch. + + // Dimming scrim. Deliberately NOT tap-to-dismiss, unlike LiquidPageJumpPopup: there is + // nothing behind this to go back to β€” dismissing would leave a locked document and an + // empty screen. Back already exits the viewer. + AnimatedVisibility( + visible = askingPassword, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(180)), + modifier = Modifier.fillMaxSize() + ) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(0.45f))) } AnimatedVisibility( - visible = state.passwordRequired && state.passwordUri != null, - enter = fadeIn() + scaleIn(initialScale = 0.96f), - exit = fadeOut() + scaleOut(targetScale = 0.98f), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .imePadding() + visible = askingPassword, + // LiquidPageJumpPopup's entrance, verbatim, so the app has one dialog motion. + enter = fadeIn(tween(220)) + + scaleIn(initialScale = 0.85f, animationSpec = spring(dampingRatio = 0.72f, stiffness = Spring.StiffnessMediumLow)), + exit = fadeOut(tween(140)) + scaleOut(targetScale = 0.9f, animationSpec = tween(150)), + modifier = Modifier.align(Alignment.Center).imePadding() ) { Column( Modifier - .fillMaxWidth() + .widthIn(max = 340.dp) + .fillMaxWidth(0.86f) + // The heavy stack, not `viewerGlass`: this is a dialog over a scrim and needs + // an edge of its own. No `containerColorOverride` β€” there is no page to sample + // in this branch, so the theme tint is the right one. .liquidGlassPanel(backdrop, uiSensor) - .padding(16.dp), + .padding(22.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + BasicText(stringResource(R.string.pdf_password_title), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) BasicText( - stringResource(R.string.pdf_password_title), - style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold) - ) - BasicText( - if (state.passwordAttemptFailed) stringResource(R.string.pdf_password_wrong) - else stringResource(R.string.pdf_password_subtitle), + if (state.passwordAttemptFailed) stringResource(R.string.pdf_password_wrong) else stringResource(R.string.pdf_password_subtitle), style = TextStyle(sub, 12.sp) ) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { BasicTextField( value = passwordText, onValueChange = { passwordText = it }, singleLine = true, textStyle = TextStyle(text, 15.sp), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - state.passwordUri?.let { viewModel.openPdf(context, it, passwordText) } - } - ), - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(12.dp)) + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { state.passwordUri?.let { viewModel.openPdf(context, it, passwordText) } }), + modifier = Modifier.weight(1f).clip(RoundedCornerShape(12.dp)) .background(if (isLight) Color.Black.copy(0.05f) else Color.White.copy(0.08f)) - .padding(horizontal = 12.dp, vertical = 11.dp) - .focusRequester(passwordFocusRequester), + .padding(horizontal = 12.dp, vertical = 11.dp).focusRequester(passwordFocusRequester), decorationBox = { inner -> - if (passwordText.isEmpty()) { - BasicText(stringResource(R.string.pdf_password_hint), style = TextStyle(sub, 14.sp)) - } + if (passwordText.isEmpty()) BasicText(stringResource(R.string.pdf_password_hint), style = TextStyle(sub, 14.sp)) inner() } ) - LiquidButton( - onClick = { - state.passwordUri?.let { viewModel.openPdf(context, it, passwordText) } - }, - backdrop = backdrop, - tint = accent - ) { + LiquidButton(onClick = { state.passwordUri?.let { viewModel.openPdf(context, it, passwordText) } }, backdrop = backdrop, tint = accent) { BasicText(stringResource(R.string.pdf_password_unlock), style = TextStyle(Color.White, 13.sp, FontWeight.SemiBold)) } } @@ -488,2134 +554,949 @@ fun PdfViewerScreen( return } + // ── PDF viewer (continuous vertical scroll, Adobe-style) ───────────────── val safePageCount = state.pageCount.coerceAtLeast(1) - val pagerState = rememberPagerState(initialPage = 0) { safePageCount } - val pagerScope = rememberCoroutineScope() + val viewerScope = rememberCoroutineScope() + val currentPageIndex = listState.firstVisibleItemIndex.coerceIn(0, safePageCount - 1) + + // Live backdrop that captures the ACTUAL rendered page column (dark bg + PDF + // pages), so the glass chrome reflects real content instead of the static + // wallpaper PNG. The chrome is drawn as siblings on top of the captured Box, so + // there is no feedback loop. + val contentBackdrop = rememberLayerBackdrop() + + // ── Adaptive chrome contrast ──────────────────────────────────────────── + // The chrome glass floats over the page, which may be a bright white note or a + // dark scan. Sample the current page's average luminance and flip the whole + // chrome palette (glass surface + foreground) so text/icons are always legible: + // dark ink on a light page, white ink on a dark page. + // + // Single-page documents used to be pinned to the dark branch. That was over-broad and is the + // reason the chrome looked stuck in dark mode: a converted .docx is usually one page, so *every* + // Word document and every one-page PDF got dark glass no matter the page or the theme. + // + // The reason for the pin was real, though. A lone page is centred (`Arrangement.Center` on the + // LazyColumn below), so the chrome can float over the near-black canvas rather than over the + // page β€” and at 66% the light surface set little value of its own, leaving buttons refracting + // black while their icons took the *dark* foreground chosen from the white page. The fix is to + // make the light surface actually opaque enough to stand on its own (78%, below) instead of + // throwing away a correct luminance reading. Dark ink on that holds up over the black band and + // over the page alike. + val currentPageBitmap = state.pageBitmaps.getOrNull(currentPageIndex) + // Whole-page luminance still drives the DIALOG panels (chromePanel / panelFg): those sit over a + // scrim on an opaque surface, not over the live document, so a single global reading is right. + // The floating BARS no longer use this β€” they sample the content directly behind each bar + // (topFg / bottomFg, computed once containerHeightPx is known, further down). That is what makes + // the icons dynamic per-region, kills the "doesn't flip until you scroll deep into page 3" lag, + // and tracks the zoomed region on a single page. The old single-page white-pin is gone with it β€” + // band sampling reads the dark letterbox as dark (β†’ white ink) on its own. + val isLightChrome = remember(currentPageBitmap) { + currentPageBitmap?.let { averageLuminance(it) > 0.60f } ?: false + } + // Home's tint, verbatim β€” the same expression GlassTitlePill and GlassSearchPill resolve β€” but + // picked off the *page's* luminance instead of the theme, so a white scan in dark mode still gets + // the light surface. + // + // This used to be 0.78/0.62, roughly twice Home's weight, and it was painted onto controls Home + // leaves entirely clear: `LiquidIconButton` with no `surfaceColor` draws nothing at all in + // `onDrawSurface`, so Home's circles are pure refraction. Over a white page a 62% #12151C slab + // composites to about #6C6C6C, which is why the chrome read as grey blobs sitting *on* the + // document rather than as glass floating over it. The effect stack was never the difference β€” + // `LiquidIconButton` and `viewerGlass` both run vibrancy + blur 2 + lens 12x24 β€” only the tint was. + val chromeGlass = if (isLightChrome) Color(0xFFFAFAFA).copy(0.35f) else Color(0xFF1E1E1E).copy(0.35f) + // Dialogs keep the old, heavier tint. They sit over a 45% black scrim and carry dense content, and + // the app's own rule is that the heavy stack stays reserved for them (see ViewerGlass's KDoc). + val chromePanel = if (isLightChrome) Color(0xFFEFF1F4).copy(0.78f) else ViewerChromeGlass + // The dialogs' ink deliberately does *not* take the single-page pin above. Their surface is + // `chromePanel`, which is opaque enough to be its own background, so it is the panel the text has + // to contrast with β€” not the canvas. Pinning these to white alongside the floating chrome would + // put white text on a 78% white panel every time a one-page document happened to be light. + val panelFg = if (isLightChrome) Color(0xFF15171C) else Color.White + val panelFgSoft = if (isLightChrome) Color(0xFF15171C).copy(0.62f) else Color.White.copy(0.62f) + val chromeField = if (isLightChrome) Color.Black.copy(0.10f) else Color.White.copy(0.10f) + + // The top visible page drives text extraction + bitmap caching. + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { idx -> viewModel.onPageChanged(idx.coerceIn(0, safePageCount - 1)) } + } + + // Scroll to the page holding the active find match. + LaunchedEffect(state.currentMatchIndex, state.findMatches) { + val idx = state.currentMatchIndex + val matches = state.findMatches + if (idx >= 0 && matches.isNotEmpty()) { + viewerScope.launch { listState.animateScrollToItem(matches[idx].pageIndex) } + } + } + + val drawingToolActive = activeTool in setOf( + PdfEditTool.Draw, PdfEditTool.Highlight, PdfEditTool.Rect, + PdfEditTool.Ellipse, PdfEditTool.Line, PdfEditTool.Arrow + ) + val zoomHudText = "${(scale * 100 + 0.5f).toInt()}%" - LaunchedEffect(pagerState.currentPage) { - zoomAnim.snapTo(1f); panXAnim.snapTo(0f); panYAnim.snapTo(0f) - viewModel.onPageChanged(pagerState.currentPage) + fun scrollToPage(target: Int) { + viewerScope.launch { listState.animateScrollToItem(target.coerceIn(0, safePageCount - 1)) } + lastInteractionAtMs = System.currentTimeMillis() + } + + // Smooth, critically-damped zoom β€” NO overshoot/bounce (premium, Apple-style ease). + val animateZoomPan: suspend (Float, Offset) -> Unit = { targetZoom, targetPan -> + val spec = androidx.compose.animation.core.spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ) + coroutineScope { + launch { androidx.compose.animation.core.animate(scale, targetZoom, animationSpec = spec) { v, _ -> scale = v } } + launch { androidx.compose.animation.core.animate(offsetX, targetPan.x, animationSpec = spec) { v, _ -> offsetX = v } } + } } BoxWithConstraints( modifier = Modifier .fillMaxSize() - .background(if (isLight) Color(0xFF0E1218).copy(0.92f) else Color(0xFF050608).copy(0.92f)) + .background(if (isLight) Color(0xFF0A0E14).copy(0.85f) else Color(0xFF020305).copy(0.88f)) ) { val renderWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() }.coerceAtLeast(720) - val drawingToolActive = activeTool in setOf( - PdfEditTool.Draw, PdfEditTool.Highlight, PdfEditTool.Rect, - PdfEditTool.Ellipse, PdfEditTool.Line, PdfEditTool.Arrow - ) - - val pagerScrollEnabled = activeTool == PdfEditTool.None && zoomScale <= 1.02f - - LaunchedEffect(pagerState.currentPage, renderWidthPx) { - viewModel.renderPage(context, pagerState.currentPage, renderWidthPx) - if (pagerState.currentPage - 1 >= 0) - viewModel.renderPage(context, pagerState.currentPage - 1, renderWidthPx) - if (pagerState.currentPage + 1 < safePageCount) - viewModel.renderPage(context, pagerState.currentPage + 1, renderWidthPx) + var containerHeightPx by remember { mutableStateOf(0) } + + // ── Adaptive control ink, sampled per bar (Apple-style) ────────────────────────────── + // Each bar reads the luminance of the content *directly behind it*, so on a page that is a + // dark image up top and a white canvas below, the top bar goes white while the bottom toolbar + // goes dark β€” and both update the instant that content scrolls or zooms under them, not when + // the page becomes the first fully-visible item. Bands are in screen px; the content Box is + // scaled about a top origin with translationY = 0, so a screen Y maps to the LazyColumn's own + // coordinate as screenY / scale (see [bandLuminance]). + val topBandBottomPx = with(density) { 96.dp.toPx() } + val bottomBandDepthPx = with(density) { 140.dp.toPx() } + var topBarLight by remember { mutableStateOf(false) } + var bottomBarLight by remember { mutableStateOf(false) } + // Hysteresis: flip to light at 0.62, back to dark at 0.58, so a page hovering near the + // midpoint doesn't strobe while scrolling. The colour crossfade below smooths the flip + // itself. The sampling runs in a coroutine off `snapshotFlow`, so scrolling recomputes the + // luminance every frame but only an actual light/dark flip ever recomposes the chrome. + LaunchedEffect(containerHeightPx) { + snapshotFlow { + bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, 0f, topBandBottomPx) + }.collect { lum -> + if (lum > 0.62f) topBarLight = true else if (lum < 0.58f) topBarLight = false + } } - - val pageContent: @Composable (Int) -> Unit = pageContent@ { page -> - val bitmap = state.pageBitmaps.getOrNull(page) - val marks = getPageMarks(page) - val ocrBlocks = state.ocrBlocksByPage[page].orEmpty() - val selectedOcrIds = state.selectedOcrBlockIdsByPage[page].orEmpty() - - var draftPoints by remember(page, activeTool) { mutableStateOf>(emptyList()) } - var draftRectStart by remember(page, activeTool) { mutableStateOf(null) } - var draftRectEnd by remember(page, activeTool) { mutableStateOf(null) } - var selDragStart by remember(page, activeTool) { mutableStateOf(null) } - var selDragEnd by remember(page, activeTool) { mutableStateOf(null) } - - if (bitmap == null) { - Box(Modifier.fillMaxSize().background(Color(0xFF0E1218)), Alignment.Center) { - CircularProgressIndicator(color = accent, strokeWidth = 2.dp) + LaunchedEffect(containerHeightPx) { + snapshotFlow { + val h = containerHeightPx.toFloat() + if (h <= 0f) 0f + else bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, h - bottomBandDepthPx, h) + }.collect { lum -> + if (lum > 0.62f) bottomBarLight = true else if (lum < 0.58f) bottomBarLight = false + } + } + val topFg by animateColorAsState(if (topBarLight) Color(0xFF15171C) else Color.White, tween(200), label = "topBarInk") + val bottomFg by animateColorAsState(if (bottomBarLight) Color(0xFF15171C) else Color.White, tween(200), label = "bottomBarInk") + val topFgSoft = topFg.copy(alpha = 0.62f) + val bottomFgSoft = bottomFg.copy(alpha = 0.62f) + + // The find bar is the one control that does NOT sit at the screen edge: with the keyboard up + // it floats at the top of the IME, so the bottom-of-screen band (behind the keyboard) is the + // wrong reading for it. Sample the band directly above the IME instead, so its text and its + // prev/next/close icons adapt to the page content actually behind the search bar. When the + // keyboard is down the IME inset is 0 and this collapses to the same bottom band. + val imeInset = WindowInsets.ime + var findBarLight by remember { mutableStateOf(false) } + LaunchedEffect(containerHeightPx) { + snapshotFlow { + val h = containerHeightPx.toFloat() + if (h <= 0f) 0f + else { + val bottom = (h - imeInset.getBottom(density)).coerceAtLeast(bottomBandDepthPx) + bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, bottom - bottomBandDepthPx, bottom) } - return@pageContent + }.collect { lum -> + if (lum > 0.62f) findBarLight = true else if (lum < 0.58f) findBarLight = false } + } + val findFg by animateColorAsState(if (findBarLight) Color(0xFF15171C) else Color.White, tween(200), label = "findBarInk") + val findFgSoft = findFg.copy(alpha = 0.62f) - pageBitmapSizes[page] = Size(bitmap.width.toFloat(), bitmap.height.toFloat()) - + // ── Continuous vertical page column with document-level zoom/pan ───── + // layerBackdrop + background live INSIDE this Box so the captured layer holds + // the dark base + PDF pages; the glass chrome samples it (real reflections). + Box( + Modifier + .fillMaxSize() + .layerBackdrop(contentBackdrop) + .background(if (isLight) Color(0xFF0A0E14).copy(0.85f) else Color(0xFF020305).copy(0.88f)) + .clipToBounds() + ) { Box( - modifier = Modifier + Modifier .fillMaxSize() - .onSizeChanged { sz -> - pageCanvasSizes[page] = Size(sz.width.toFloat(), sz.height.toFloat()) - } - ) { - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(page, activeTool, scrollOrientation) { - if (activeTool != PdfEditTool.None) return@pointerInput - + .onSizeChanged { containerHeightPx = it.height } + .then( + // Keep pinch zoom available in Select Text mode. Other editing tools own + // the page gesture surface because their strokes/shapes need the drag. + if (activeTool != PdfEditTool.None && activeTool != PdfEditTool.SelectText) Modifier + else Modifier.pointerInput(Unit) { awaitEachGesture { awaitFirstDown(requireUnconsumed = false) - - val velocityTracker = VelocityTracker() - var panAccum = Offset.Zero - var determined = false - var isOurGesture = false - - loop@ while (true) { - val event = awaitPointerEvent(PointerEventPass.Main) - val pressed = event.changes.filter { it.pressed } - - if (pressed.isEmpty()) { - val currentZoom = zoomAnim.value - if (currentZoom < SNAP_BACK_THRESHOLD && currentZoom > 0.95f) { - scope.launch { - animateZoomPan( - targetZoom = 1f, - targetPan = Offset.Zero, - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ) - } - } - else if (isOurGesture && currentZoom > 1.02f) { - val vel = velocityTracker.calculateVelocity() - val cs = pageCanvasSizes[page] ?: Size.Zero - val bs = pageBitmapSizes[page] ?: Size.Zero - scope.launch { - var vx = vel.x * 0.016f - var vy = vel.y * 0.016f - while (vx * vx + vy * vy > FLING_MIN_VELOCITY * 0.016f * FLING_MIN_VELOCITY * 0.016f) { - val newPan = clampPanOffset( - Offset(panXAnim.value + vx, panYAnim.value + vy), - zoomAnim.value, cs, bs - ) - snapZoomPan(zoomAnim.value, newPan) - vx *= FLING_FRICTION - vy *= FLING_FRICTION - delay(16L) - } - } - } - break@loop - } - - if (pressed.size >= 2) { - if (!determined) { determined = true; isOurGesture = true } - - val zoomDelta = event.calculateZoom() - val panDelta = event.calculatePan() - val centroid = event.calculateCentroid(useCurrent = false) - val centSize = event.calculateCentroidSize(useCurrent = false) - val cs = pageCanvasSizes[page] ?: Size.Zero - val bs = pageBitmapSizes[page] ?: Size.Zero - val boxCenter = Offset(cs.width / 2f, cs.height / 2f) - - if (centSize > 0f && abs(zoomDelta - 1f) > ZOOM_SLOP) { - val oldScale = zoomAnim.value - val boosted = if (zoomDelta > 1f) - zoomDelta.toDouble().pow(1.18).toFloat().coerceIn(1f, 1.30f) - else - zoomDelta.coerceIn(0.78f, 1f) - val newScale = (oldScale * boosted).coerceIn(MIN_ZOOM, MAX_ZOOM) - - val ratio = newScale / oldScale - val currentPan = Offset(panXAnim.value, panYAnim.value) - val focalPan = (centroid - boxCenter) * (1f - ratio) + currentPan * ratio - val clampedPan = clampPanOffset(focalPan + panDelta * ratio, newScale, cs, bs) - - scope.launch { snapZoomPan(newScale, clampedPan) } - lastInteractionAtMs = System.currentTimeMillis() - } else if (pressed.size >= 2 && panDelta != Offset.Zero) { - val cs2 = pageCanvasSizes[page] ?: Size.Zero - val bs2 = pageBitmapSizes[page] ?: Size.Zero - val newPan = clampPanOffset( - Offset(panXAnim.value, panYAnim.value) + panDelta, - zoomAnim.value, cs2, bs2 - ) - scope.launch { snapZoomPan(zoomAnim.value, newPan) } - lastInteractionAtMs = System.currentTimeMillis() - } - - event.changes.forEach { it.consume() } - continue@loop - } - - val change = pressed.first() - val delta = change.position - change.previousPosition - - velocityTracker.addPosition( - change.uptimeMillis, change.position - ) - - if (!determined) { - panAccum += delta - val dist = panAccum.getDistance() - if (dist > PAN_SLOP_PX) { - determined = true - val angleDeg = Math.toDegrees( - abs(atan2(panAccum.y.toDouble(), panAccum.x.toDouble())) - ).toFloat() - val isHoriz = angleDeg <= SWIPE_ANGLE_DEG - || angleDeg >= (180f - SWIPE_ANGLE_DEG) - - val isSwipeForPager = if (scrollOrientation == ScrollOrientation.Horizontal) isHoriz else !isHoriz - - if (isSwipeForPager && zoomAnim.value <= 1.02f) { - isOurGesture = false - break@loop - } - isOurGesture = true + do { + val event = awaitPointerEvent() + val zoomChange = event.calculateZoom() + val panChange = event.calculatePan() + val zoomed = scale > 1.001f + if (zoomChange != 1f || zoomed) { + val cw = size.width.toFloat() + val oldScale = scale + val newScale = (oldScale * zoomChange).coerceIn(1f, 5f) + // Real zoom ratio AFTER clamping β€” drives the focal correction. + val effZoom = if (oldScale != 0f) newScale / oldScale else 1f + val centroid = event.calculateCentroid(useCurrent = true) + val center = cw / 2f + val maxOffsetX = ((cw * newScale - cw) / 2f).coerceAtLeast(0f) + // Synchronous state writes β€” no per-event coroutine (Pdf_Tools parity). + scale = newScale + if (newScale > 1f) { + // FOCAL zoom: shift so the pinch centroid stays under the fingers + // (layer origin is top-CENTER on X), THEN apply the finger pan. + // Without this the page scaled around the centre, so zoom landed + // "beside" where you pinched. + val focalDx = if (centroid.isSpecified) (1f - effZoom) * (centroid.x - center - offsetX) else 0f + offsetX = (offsetX + focalDx + panChange.x).coerceIn(-maxOffsetX, maxOffsetX) + // Vertical is LazyColumn scroll in UNSCALED px (layer origin is TOP + // on Y): the focal term keeps the centroid's row put, the pan term + // tracks the finger (divided by scale for 1:1 at higher zoom). + val focalDy = if (centroid.isSpecified) centroid.y * (1f / oldScale - 1f / newScale) else 0f + val totalDy = focalDy + (-panChange.y / newScale) + if (totalDy != 0f) listState.dispatchRawDelta(totalDy) + } else { + offsetX = 0f } - } else if (isOurGesture && zoomAnim.value > 1.02f) { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bs = pageBitmapSizes[page] ?: Size.Zero - val newPan = clampPanOffset( - Offset(panXAnim.value + delta.x, panYAnim.value + delta.y), - zoomAnim.value, cs, bs - ) - scope.launch { snapZoomPan(zoomAnim.value, newPan) } - change.consume() lastInteractionAtMs = System.currentTimeMillis() } - } + } while (event.changes.any { it.pressed }) } } - .pointerInput(page, activeTool) { - detectTapGestures( - onTap = { tap -> - if (activeTool == PdfEditTool.Image && activeImageId != null) { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - val p = screenToContent(tap, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - val marks = getPageMarks(page) - val idx = marks.indexOfLast { - if (it is PdfMarkup.ImageMarkup) { - val r = Rect(min(it.start.x, it.end.x), min(it.start.y, it.end.y), max(it.start.x, it.end.x), max(it.start.y, it.end.y)) - r.contains(p) - } else false - } - if (idx >= 0) { - val img = marks[idx] as PdfMarkup.ImageMarkup - activeImageId = img.id - controlsVisible = true - } else { - activeImageId = null - activeTool = PdfEditTool.None - controlsVisible = !controlsVisible - } - } else if (activeTool == PdfEditTool.None) { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - val p = screenToContent(tap, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - val marks = getPageMarks(page) - val idx = marks.indexOfLast { - if (it is PdfMarkup.ImageMarkup) { - val r = Rect(min(it.start.x, it.end.x), min(it.start.y, it.end.y), max(it.start.x, it.end.x), max(it.start.y, it.end.y)) - r.contains(p) - } else false - } - if (idx >= 0) { - val img = marks[idx] as PdfMarkup.ImageMarkup - activeImageId = img.id - activeTool = PdfEditTool.Image - controlsVisible = true - } else { - controlsVisible = !controlsVisible - } - } else { - controlsVisible = !controlsVisible - } - lastInteractionAtMs = System.currentTimeMillis() - }, - onDoubleTap = { tap -> - if (activeTool != PdfEditTool.None) return@detectTapGestures - lastInteractionAtMs = System.currentTimeMillis() - - val cs = pageCanvasSizes[page] ?: return@detectTapGestures - val bs = pageBitmapSizes[page] ?: return@detectTapGestures - val boxCenter = Offset(cs.width / 2f, cs.height / 2f) - - val currentZoom = zoomAnim.value - val targetZoom = when { - currentZoom < 1.5f -> DOUBLE_TAP_ZOOM_1 - currentZoom < 3.5f -> DOUBLE_TAP_ZOOM_2 - else -> 1f - } - - val targetPan = if (targetZoom <= 1.01f) { - Offset.Zero - } else { - val ratio = targetZoom / currentZoom - val currentPan = Offset(panXAnim.value, panYAnim.value) - val focalPan = (tap - boxCenter) * (1f - ratio) + currentPan * ratio - clampPanOffset(focalPan, targetZoom, cs, bs) - } - - scope.launch { - animateZoomPan( - targetZoom = targetZoom, - targetPan = targetPan, - dampingRatio = if (targetZoom < currentZoom) - Spring.DampingRatioMediumBouncy - else Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMediumLow - ) - } - } - ) - } - ) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = zoomScale, - scaleY = zoomScale, - translationX = panOffset.x, - translationY = panOffset.y - ) - ) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = stringResource(R.string.page_number, page + 1), - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize() ) - - androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { - val ir = fitBitmapRect(size, bitmap.width.toFloat(), bitmap.height.toFloat()) - - selectedOcrIds.forEach { id -> - ocrBlocks.firstOrNull { it.id == id }?.let { b -> - val r = ocrBlockToRect(b, ir) - drawRect(Color(0xFFFFEB3B).copy(0.30f), r.topLeft, r.size) - } - } - if (activeTool == PdfEditTool.SelectText) { - ocrBlocks.forEach { b -> - val r = ocrBlockToRect(b, ir) - drawRect(Color.White.copy(0.18f), r.topLeft, r.size, style = Stroke(1f)) + .pointerInput(activeTool) { + detectTapGestures( + onTap = { if (activeTool == PdfEditTool.None) controlsVisible = !controlsVisible; lastInteractionAtMs = System.currentTimeMillis() }, + onDoubleTap = { tap -> + if (activeTool != PdfEditTool.None) return@detectTapGestures + val cw = size.width.toFloat() + val s0 = scale + val target = if (s0 > 1.5f) 1f else 2.5f + val maxOffsetX = ((cw * target - cw) / 2f).coerceAtLeast(0f) + // Horizontal focal: keep the tapped X under the finger + // (graphicsLayer origin is top-CENTER, so translationX compensates). + val focalX = if (target > 1f) ((cw / 2f - tap.x) * (target - 1f)).coerceIn(-maxOffsetX, maxOffsetX) else 0f + // Vertical focal: the layer is TOP-anchored, so keep the tapped Y + // under the finger by scrolling the list Ξ” = tap.y*(1/s0 - 1/target). + val scrollDelta = tap.y * (1f / s0 - 1f / target) + val focalSpec = androidx.compose.animation.core.spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ) + viewerScope.launch { animateZoomPan(target, Offset(focalX, 0f)) } + viewerScope.launch { listState.animateScrollBy(scrollDelta, focalSpec) } + lastInteractionAtMs = System.currentTimeMillis() } - } + ) + } + ) { + val extraBottomPadding = if (scale > 1f && containerHeightPx > 0) + with(LocalDensity.current) { (containerHeightPx * ((scale - 1f) / scale)).toDp() } else 0.dp - marks.forEach { markup -> - when (markup) { - is PdfMarkup.StrokeMarkup -> { - if (markup.points.size > 1) { - val path = smoothPath(markup.points) - drawPath( - path, markup.color.copy(markup.alpha), - style = Stroke(markup.width, cap = StrokeCap.Round, join = StrokeJoin.Round) - ) - } - } - is PdfMarkup.RectMarkup -> { - val r = Rect( - min(markup.start.x, markup.end.x), min(markup.start.y, markup.end.y), - max(markup.start.x, markup.end.x), max(markup.start.y, markup.end.y) - ) - if (markup.filled) drawRect(markup.color.copy(markup.alpha), r.topLeft, r.size) - else drawRect(markup.color.copy(markup.alpha), r.topLeft, r.size, style = Stroke(3f)) - } - is PdfMarkup.OvalMarkup -> { - val r = Rect( - min(markup.start.x, markup.end.x), min(markup.start.y, markup.end.y), - max(markup.start.x, markup.end.x), max(markup.start.y, markup.end.y) - ) - if (markup.filled) drawOval(markup.color.copy(markup.alpha), r.topLeft, r.size) - else drawOval(markup.color.copy(markup.alpha), r.topLeft, r.size, style = Stroke(3f)) - } - is PdfMarkup.LineMarkup -> { - if (markup.arrowHead) - drawArrow(markup.start, markup.end, markup.color.copy(markup.alpha), markup.width) - else - drawLine(markup.color.copy(markup.alpha), markup.start, markup.end, markup.width) - } - is PdfMarkup.TextBlockHighlightMarkup -> { - ocrBlocks.firstOrNull { it.id == markup.blockId }?.let { b -> - val r = ocrBlockToRect(b, ir) - drawRect(markup.color.copy(markup.alpha), r.topLeft, r.size) + Box( + Modifier.fillMaxSize().graphicsLayer { + scaleX = scale; scaleY = scale + translationX = offsetX; translationY = 0f + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f) + } + ) { + LazyColumn( + state = listState, + flingBehavior = pageFling, + userScrollEnabled = activeTool == PdfEditTool.None && scale <= 1.01f, + modifier = Modifier.fillMaxSize(), + // Center the block when it's shorter than the viewport (e.g. a + // single page) so it sits neatly centered instead of pinned to + // the top with a dark blank half below. No effect once content + // overflows (multi-page / zoomed), where it scrolls normally. + verticalArrangement = Arrangement.Center, + contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 4.dp, bottom = 4.dp + extraBottomPadding) + ) { + items(count = safePageCount, key = { it }, contentType = { "pdfPage" }) { page -> + // Re-request whenever this page has no bitmap (covers cache eviction + // while the item stays composed in the lazy list). + val needsRender = state.pageBitmaps.getOrNull(page) == null + LaunchedEffect(page, renderWidthPx, needsRender) { + if (needsRender) viewModel.renderPage(context, page, renderWidthPx) + } + PdfContinuousPage( + page = page, + bitmap = state.pageBitmaps.getOrNull(page), + marks = getPageMarks(page), + ocrBlocks = state.ocrBlocksByPage[page].orEmpty(), + selectedOcrIds = state.selectedOcrBlockIdsByPage[page].orEmpty(), + selectedOcrRanges = state.selectedOcrRangesByPage[page].orEmpty(), + findMatches = state.findMatches, + currentMatchIndex = state.currentMatchIndex, + showFindBar = showFindBar, + activeTool = activeTool, + currentColor = currentColor, + currentStrokeWidth = currentStrokeWidth, + activeImageId = activeImageId, + pageCanvasSizes = pageCanvasSizes, + pageBitmapSizes = pageBitmapSizes, + onInteraction = { lastInteractionAtMs = System.currentTimeMillis() }, + onMarkAdded = { recordEdit(page) }, + onToggleControls = { controlsVisible = !controlsVisible }, + onShowControls = { controlsVisible = true }, + onActiveToolChanged = { activeTool = it }, + onActiveImageIdChanged = { activeImageId = it }, + onClearOcrSelection = { viewModel.clearOcrSelection(page) }, + // Replace (not append): a drag defines the whole selection live, so as + // the finger shrinks the range the deselected words must drop out too. + onSelectOcrRange = { viewModel.selectOcrRanges(page, it, append = false) }, + onPlaceText = { pt -> + val id = System.nanoTime() + getPageMarks(page).add(PdfMarkup.TextBoxMarkup(id, pt, "", currentColor, 40f)) + recordEdit(page) + editingAnnoId = id; editingAnnoPage = page; editingAnnoIsNote = false; annotationDraft = "" + activeTool = PdfEditTool.None; controlsVisible = true + }, + onPlaceNote = { pt -> + val id = System.nanoTime() + getPageMarks(page).add(PdfMarkup.NoteMarkup(id, pt, "", Color(0xFFFFC107))) + recordEdit(page) + editingAnnoId = id; editingAnnoPage = page; editingAnnoIsNote = true; annotationDraft = "" + activeTool = PdfEditTool.None; controlsVisible = true + }, + onEditAnnotation = { id -> + val m = getPageMarks(page).firstOrNull { + (it is PdfMarkup.TextBoxMarkup && it.id == id) || (it is PdfMarkup.NoteMarkup && it.id == id) } - } - is PdfMarkup.TextBlockLineMarkup -> { - ocrBlocks.firstOrNull { it.id == markup.blockId }?.let { b -> - val r = ocrBlockToRect(b, ir) - val y = if (markup.strikeThrough) r.center.y else r.bottom - r.height * 0.10f - drawLine(markup.color.copy(markup.alpha), Offset(r.left, y), Offset(r.right, y), markup.width) + editingAnnoId = id; editingAnnoPage = page + editingAnnoIsNote = m is PdfMarkup.NoteMarkup + annotationDraft = when (m) { + is PdfMarkup.TextBoxMarkup -> m.text + is PdfMarkup.NoteMarkup -> m.text + else -> "" } - } - is PdfMarkup.ImageMarkup -> { - val r = Rect( - min(markup.start.x, markup.end.x), min(markup.start.y, markup.end.y), - max(markup.start.x, markup.end.x), max(markup.start.y, markup.end.y) - ) - if (!markup.bitmap.isRecycled && markup.bitmap.width > 0 && markup.bitmap.height > 0) { - runCatching { - drawImage( - image = markup.bitmap.asImageBitmap(), - srcOffset = androidx.compose.ui.unit.IntOffset.Zero, - srcSize = androidx.compose.ui.unit.IntSize(markup.bitmap.width, markup.bitmap.height), - dstOffset = androidx.compose.ui.unit.IntOffset(r.left.toInt(), r.top.toInt()), - dstSize = androidx.compose.ui.unit.IntSize(r.width.toInt().coerceAtLeast(1), r.height.toInt().coerceAtLeast(1)) - ) - } + editingAnnoColor = when (m) { + is PdfMarkup.TextBoxMarkup -> m.color + is PdfMarkup.NoteMarkup -> m.color + else -> Color(0xFF1976D2) } - if (activeTool == PdfEditTool.Image && markup.id == activeImageId) { - drawRect(Color(0xFF1976D2), r.topLeft, r.size, style = Stroke(2f)) - drawCircle(Color(0xFF1976D2), 14f, Offset(r.right, r.bottom)) - drawCircle(Color.White, 7f, Offset(r.right, r.bottom)) + controlsVisible = true + }, + onEditShape = { idx -> + editingShapePage = page; editingShapeIndex = idx; controlsVisible = true + }, + selectedMarkupIndex = if (page == selectedAnnoPage) selectedAnnoIndex else -1, + onSelectMarkup = { idx -> + if (idx < 0) { selectedAnnoPage = null; selectedAnnoIndex = -1 } + else { selectedAnnoPage = page; selectedAnnoIndex = idx } + }, + onDeleteMarkup = { idx -> + val m = getPageMarks(page); if (idx in m.indices) m.removeAt(idx) + selectedAnnoPage = null; selectedAnnoIndex = -1 + }, + onCopySelection = { + viewModel.getSelectedOcrText(page).takeIf { it.isNotBlank() } + ?.let { clipboard.setText(AnnotatedString(it)) } + lastInteractionAtMs = System.currentTimeMillis() + }, + onHighlightSelection = { + val m = getPageMarks(page) + selectedTextRanges(page).forEach { range -> + if (!m.any { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end }) + m.add(PdfMarkup.TextBlockHighlightMarkup(range.blockId, Color(currentColorLong), 0.38f, range.start, range.end)) } + viewModel.clearOcrSelection(page) } - } - } - - if (draftPoints.size > 1) { - val path = smoothPath(draftPoints) - val isHl = activeTool == PdfEditTool.Highlight - drawPath( - path, - currentColor.copy(if (isHl) 0.32f else 0.95f), - style = Stroke( - if (isHl) currentStrokeWidth * 3.5f else currentStrokeWidth, - cap = StrokeCap.Round, join = StrokeJoin.Round - ) ) } - - if (draftRectStart != null && draftRectEnd != null) { - val s = draftRectStart!!; val e = draftRectEnd!! - val pr = Rect(min(s.x, e.x), min(s.y, e.y), max(s.x, e.x), max(s.y, e.y)) - when (activeTool) { - PdfEditTool.Rect -> drawRect(Color(0xFF42A5F5), pr.topLeft, pr.size, style = Stroke(3f)) - PdfEditTool.Ellipse -> drawOval(Color(0xFF26A69A), pr.topLeft, pr.size, style = Stroke(3f)) - PdfEditTool.Line -> drawLine(Color(0xFF66BB6A), s, e, 4f) - PdfEditTool.Arrow -> drawArrow(s, e, Color(0xFFEF5350), 4f) - else -> Unit - } - } - - if (selDragStart != null && selDragEnd != null && activeTool == PdfEditTool.SelectText) { - val s = selDragStart!!; val e = selDragEnd!! - val r = Rect(min(s.x, e.x), min(s.y, e.y), max(s.x, e.x), max(s.y, e.y)) - drawRect(Color(0xFFAB47BC).copy(0.20f), r.topLeft, r.size) - drawRect(Color(0xFFAB47BC), r.topLeft, r.size, style = Stroke(2f)) - } - - if (showFindBar && state.findMatches.isNotEmpty()) { - val pageMatches = state.findMatches.filter { it.pageIndex == page } - val ocrBlocks = state.ocrBlocksByPage[page].orEmpty() - val cs = pageCanvasSizes[page] ?: Size.Zero - val bs = pageBitmapSizes[page] ?: Size.Zero - if (cs != Size.Zero && bs != Size.Zero && ocrBlocks.isNotEmpty()) { - val frame = fitBitmapRect(cs, bs.width, bs.height) - pageMatches.forEach { match -> - val block = ocrBlocks.firstOrNull { it.id == match.blockId } - if (block != null) { - val r = Rect( - frame.left + block.left * frame.width, - frame.top + block.top * frame.height, - frame.left + block.right * frame.width, - frame.top + block.bottom * frame.height - ) - val isCurrent = (state.findMatches.getOrNull(state.currentMatchIndex) == match) - if (isCurrent) { - drawRect(Color(0xFFFF9800).copy(0.60f), r.topLeft, r.size) - drawRect(Color(0xFFE65100), r.topLeft, r.size, style = Stroke(3.5f)) - } else { - drawRect(Color(0xFFFFEB3B).copy(0.40f), r.topLeft, r.size) - drawRect(Color(0xFFFBC02D), r.topLeft, r.size, style = Stroke(1.5f)) - } - } - } - } - } } - } } + } + } - if (drawingToolActive) { - Box( - Modifier.fillMaxSize().pointerInput(page, activeTool) { - fun toContent(o: Offset): Offset { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - return screenToContent(o, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - } - val freehand = activeTool == PdfEditTool.Draw || activeTool == PdfEditTool.Highlight - detectDragGestures( - onDragStart = { start -> - lastInteractionAtMs = System.currentTimeMillis() - val p = toContent(start) - if (freehand) draftPoints = listOf(p) - else { draftRectStart = p; draftRectEnd = p } - }, - onDrag = { change, _ -> - change.consume() - lastInteractionAtMs = System.currentTimeMillis() - val p = toContent(change.position) - if (freehand) draftPoints = draftPoints + p - else draftRectEnd = p - }, - onDragCancel = { draftPoints = emptyList(); draftRectStart = null; draftRectEnd = null }, - onDragEnd = { - when (activeTool) { - PdfEditTool.Draw -> if (draftPoints.size > 1) marks.add(PdfMarkup.StrokeMarkup(draftPoints, currentColor, currentStrokeWidth, 0.95f)) - PdfEditTool.Highlight -> if (draftPoints.size > 1) marks.add(PdfMarkup.StrokeMarkup(draftPoints, currentColor, currentStrokeWidth * 3.5f, 0.32f)) - PdfEditTool.Rect -> draftRectStart?.let { s -> draftRectEnd?.let { e -> marks.add(PdfMarkup.RectMarkup(s, e, currentColor, 1f, false)) } } - PdfEditTool.Ellipse -> draftRectStart?.let { s -> draftRectEnd?.let { e -> marks.add(PdfMarkup.OvalMarkup(s, e, currentColor, 1f, false)) } } - PdfEditTool.Line -> draftRectStart?.let { s -> draftRectEnd?.let { e -> marks.add(PdfMarkup.LineMarkup(s, e, currentColor, currentStrokeWidth, 1f, false)) } } - PdfEditTool.Arrow -> draftRectStart?.let { s -> draftRectEnd?.let { e -> marks.add(PdfMarkup.LineMarkup(s, e, currentColor, currentStrokeWidth, 1f, true)) } } - else -> Unit - } - draftPoints = emptyList(); draftRectStart = null; draftRectEnd = null - } - ) - } - ) - } - - if (activeTool == PdfEditTool.Eraser) { - Box( - Modifier.fillMaxSize().pointerInput(page) { - fun toContent(o: Offset): Offset { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - return screenToContent(o, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - } - fun eraseAt(o: Offset) { - val p = toContent(o) - val idx = marks.indexOfLast { it.hitTest(p) } - if (idx >= 0) marks.removeAt(idx) - } - detectTapGestures { eraseAt(it); lastInteractionAtMs = System.currentTimeMillis() } - }.pointerInput(page) { - fun toContent(o: Offset): Offset { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - return screenToContent(o, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - } - detectDragGestures(onDrag = { change, _ -> - change.consume() - val p = toContent(change.position) - val idx = marks.indexOfLast { it.hitTest(p) } - if (idx >= 0) marks.removeAt(idx) - lastInteractionAtMs = System.currentTimeMillis() - }) - } - ) - } - - if (activeTool == PdfEditTool.Image && activeImageId != null) { - Box( - Modifier.fillMaxSize() - .pointerInput(page, activeImageId) { - fun toContent(o: Offset): Offset { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - return screenToContent(o, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - } - detectTapGestures { tapPos -> - val p = toContent(tapPos) - val m = getPageMarks(page) - val tappedMark = m.lastOrNull { it is PdfMarkup.ImageMarkup && it.hitTest(p) } as? PdfMarkup.ImageMarkup - if (tappedMark != null) { - activeImageId = tappedMark.id - activeTool = PdfEditTool.Image - controlsVisible = true - } else { - activeImageId = null - activeTool = PdfEditTool.None - controlsVisible = !controlsVisible - } - } - } - .pointerInput(page, activeImageId) { - fun toContent(o: Offset): Offset { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - return screenToContent(o, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - } - var resizing = false - detectDragGestures( - onDragStart = { start -> - val p = toContent(start) - val idx = marks.indexOfLast { it is PdfMarkup.ImageMarkup && it.id == activeImageId } - val img = marks.getOrNull(idx) as? PdfMarkup.ImageMarkup - resizing = img != null && (p - img.end).getDistance() <= 36f - lastInteractionAtMs = System.currentTimeMillis() - }, - onDrag = { change, dragAmount -> - change.consume() - val idx = marks.indexOfLast { it is PdfMarkup.ImageMarkup && it.id == activeImageId } - val img = marks.getOrNull(idx) as? PdfMarkup.ImageMarkup ?: return@detectDragGestures - val d = dragAmount / zoomAnim.value - marks[idx] = if (resizing) { - val newEnd = Offset( - (img.end.x + d.x).coerceAtLeast(img.start.x + 24f), - (img.end.y + d.y).coerceAtLeast(img.start.y + 24f) - ) - img.copy(end = newEnd) - } else { - img.copy(start = img.start + d, end = img.end + d) - } - lastInteractionAtMs = System.currentTimeMillis() - } - ) - } - ) - } else if (activeTool == PdfEditTool.None) { - Box( - Modifier.fillMaxSize().pointerInput(page) { - fun toContent(o: Offset): Offset { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - return screenToContent(o, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - } - detectTapGestures { tapPos -> - val p = toContent(tapPos) - val m = getPageMarks(page) - val tappedMark = m.lastOrNull { it is PdfMarkup.ImageMarkup && it.hitTest(p) } as? PdfMarkup.ImageMarkup - if (tappedMark != null) { - activeImageId = tappedMark.id - activeTool = PdfEditTool.Image - controlsVisible = true - } else { - controlsVisible = !controlsVisible - } - } - } - ) - } - - if (activeTool == PdfEditTool.SelectText) { - Box( - Modifier.fillMaxSize() - .pointerInput(page, ocrBlocks) { - fun toContent(o: Offset): Offset { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - return screenToContent(o, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - } - detectTapGestures( - onDoubleTap = { tap -> - lastInteractionAtMs = System.currentTimeMillis() - val cs = pageCanvasSizes[page] ?: return@detectTapGestures - val bs = pageBitmapSizes[page] ?: return@detectTapGestures - val frame = fitBitmapRect(cs, bs.width, bs.height) - val hit = hitTestOcrBlock(ocrBlocks, toContent(tap), frame) - if (hit != null) viewModel.selectLine(page, hit.id) - }, - onLongPress = { tap -> - lastInteractionAtMs = System.currentTimeMillis() - val cs = pageCanvasSizes[page] ?: return@detectTapGestures - val bs = pageBitmapSizes[page] ?: return@detectTapGestures - val frame = fitBitmapRect(cs, bs.width, bs.height) - val hit = hitTestOcrBlock(ocrBlocks, toContent(tap), frame) - if (hit != null) viewModel.selectParagraph(page, hit.id) - }, - onTap = { tap -> - lastInteractionAtMs = System.currentTimeMillis() - val cs = pageCanvasSizes[page] ?: return@detectTapGestures - val bs = pageBitmapSizes[page] ?: return@detectTapGestures - val frame = fitBitmapRect(cs, bs.width, bs.height) - val hit = hitTestOcrBlock(ocrBlocks, toContent(tap), frame) - if (hit != null) viewModel.toggleOcrSelection(page, hit.id) - else viewModel.clearOcrSelection(page) - } - ) - } - .pointerInput(page, ocrBlocks) { - fun toContent(o: Offset): Offset { - val cs = pageCanvasSizes[page] ?: Size.Zero - val bc = Offset(cs.width / 2f, cs.height / 2f) - return screenToContent(o, zoomAnim.value, Offset(panXAnim.value, panYAnim.value), bc) - } - detectDragGesturesAfterLongPress( - onDragStart = { s -> - lastInteractionAtMs = System.currentTimeMillis() - val p = toContent(s) - selDragStart = p; selDragEnd = p - }, - onDrag = { change, _ -> - change.consume() - lastInteractionAtMs = System.currentTimeMillis() - selDragEnd = toContent(change.position) - }, - onDragCancel = { selDragStart = null; selDragEnd = null }, - onDragEnd = { - val s = selDragStart; val e = selDragEnd - selDragStart = null; selDragEnd = null - if (s == null || e == null || ocrBlocks.isEmpty()) return@detectDragGesturesAfterLongPress - val cs = pageCanvasSizes[page] ?: return@detectDragGesturesAfterLongPress - val bs = pageBitmapSizes[page] ?: return@detectDragGesturesAfterLongPress - val frame = fitBitmapRect(cs, bs.width, bs.height) - val dist = kotlin.math.hypot((e.x - s.x).toDouble(), (e.y - s.y).toDouble()) - if (dist < 12.0) { - hitTestOcrBlock(ocrBlocks, s, frame) - ?.let { viewModel.toggleOcrSelection(page, it.id) } - return@detectDragGesturesAfterLongPress - } - val marquee = Rect(min(s.x, e.x), min(s.y, e.y), max(s.x, e.x), max(s.y, e.y)) - viewModel.selectOcrBlocks( - page, - ocrBlocks.filter { intersects(marquee, ocrBlockToRect(it, frame)) } - .map { it.id }.toSet(), - append = true - ) - } - ) - } - ) - } - - AnimatedVisibility( - visible = showZoomHud, - enter = fadeIn(tween(120)), - exit = fadeOut(tween(300)), - modifier = Modifier.align(Alignment.TopCenter).padding(top = 60.dp) - ) { - Box( - Modifier - .clip(RoundedCornerShape(20.dp)) - .background(Color.Black.copy(alpha = 0.55f)) - .padding(horizontal = 14.dp, vertical = 6.dp) - ) { - BasicText( - zoomHudText, - style = TextStyle( - color = Color.White, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold - ) - ) - } - } - - } - } - - if (scrollOrientation == ScrollOrientation.Vertical) { - VerticalPager( - state = pagerState, - userScrollEnabled = pagerScrollEnabled, - modifier = Modifier.fillMaxSize(), - pageContent = { page -> pageContent(page) } - ) - } else { - HorizontalPager( - state = pagerState, - userScrollEnabled = pagerScrollEnabled, - modifier = Modifier.fillMaxSize(), - pageContent = { page -> pageContent(page) } - ) - } - + // ── Page scrubber (doubles as the fading scroll indicator) ───────── + // Always present on multi-page docs so fast scrubbing is one drag away, + // independent of the auto-hiding chrome. It brightens the moment the list + // scrolls/flings and gently fades back to a slim idle state when at rest, + // so it reads as an iOS-style scroll indicator without a second element. + // Held while the rail itself is being dragged. Dragging it does not scroll the list (that + // only happens on release), so without this the idle fade would run mid-drag and take the + // thumbnail preview down to 40% opacity β€” which reads as the preview "half disappearing". + var scrubberDragging by remember { mutableStateOf(false) } + val scrubberBright = listState.isScrollInProgress || scrubberDragging + val scrubberAlpha by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (scrubberBright) 1f else 0.4f, + animationSpec = tween(durationMillis = if (scrubberBright) 120 else 600), + label = "scrubberFade" + ) AnimatedVisibility( - visible = controlsVisible && safePageCount > 1, - enter = fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)) + - scaleIn(initialScale = 0.92f, animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)), - exit = fadeOut(animationSpec = spring(stiffness = Spring.StiffnessMedium)) + - scaleOut(targetScale = 0.92f), - modifier = Modifier - .align(Alignment.CenterEnd) - .navigationBarsPadding() - .statusBarsPadding() - .padding(end = 8.dp) + visible = safePageCount > 1 && scale <= 1.01f, + enter = fadeIn(androidx.compose.animation.core.spring(stiffness = Spring.StiffnessMedium)) + scaleIn(initialScale = 0.92f, animationSpec = androidx.compose.animation.core.spring(dampingRatio = Spring.DampingRatioMediumBouncy)), + exit = fadeOut(androidx.compose.animation.core.spring(stiffness = Spring.StiffnessMedium)) + scaleOut(targetScale = 0.92f), + modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { alpha = scrubberAlpha }.navigationBarsPadding().statusBarsPadding().padding(end = 8.dp) ) { PageScrubber( - currentPage = pagerState.currentPage, - pageCount = safePageCount, - pageBitmaps = state.pageBitmaps, - backdrop = backdrop, - uiSensor = uiSensor, - onPageChange = { page -> - pagerScope.launch { pagerState.animateScrollToPage(page) } - lastInteractionAtMs = System.currentTimeMillis() - }, - onPageScrubbing = { page -> - viewModel.renderPage(context, page, 400) - lastInteractionAtMs = System.currentTimeMillis() - } + currentPage = currentPageIndex, + pageCount = safePageCount, + pageBitmaps = state.pageBitmaps, + backdrop = contentBackdrop, + uiSensor = uiSensor, + onPageChange = { page -> scrollToPage(page) }, + onPageScrubbing = { page -> viewModel.renderPage(context, page, 400); lastInteractionAtMs = System.currentTimeMillis() }, + onDraggingChange = { scrubberDragging = it }, + isScrolling = listState.isScrollInProgress ) } + // ── Top + bottom controls overlay ────────────────────────────────── Box( - Modifier - .fillMaxSize() - .statusBarsPadding() - .navigationBarsPadding() - .padding(16.dp) + Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().padding(16.dp) ) { + // Top bar AnimatedVisibility( - visible = controlsVisible, - enter = fadeIn(tween(200)) + androidx.compose.animation.slideInVertically { -it / 2 }, - exit = fadeOut(tween(150)) + androidx.compose.animation.slideOutVertically { -it / 2 }, + visible = controlsVisible, + enter = fadeIn(tween(200)) + slideInVertically { -it / 2 }, + exit = fadeOut(tween(150)) + slideOutVertically { -it / 2 }, modifier = Modifier.align(Alignment.TopCenter) ) { + // Home and Tools' header trio, verbatim: back circle Β· centred [GlassTitlePill] Β· + // search circle, 10 dp apart. The pill is the same widget carrying "ClearPDF" on + // Home rather than a look-alike `LiquidButton`, so the two screens can't drift; only + // the palette differs, because the viewer picks its chrome from the *page's* + // luminance instead of the theme. + // + // Page navigation lives in the right-edge scrubber + scroll, so no oversized + // prev/next controls cover the document. Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() ) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.Black.copy(0.35f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), Color.White) + // No `surfaceColor`, exactly as Home calls it: the circle paints nothing of its own + // and is pure refraction. Only the icon's colour adapts to the page. + LiquidIconButton(onClick = onBack, backdrop = contentBackdrop) { + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(16.dp), topFg) + } + // A weighted Box rather than two weighted spacers: the back circle and the + // search circle are the same 40 dp, so this centres the pill on the row exactly + // the way Home's header does, and a long "Page 100 / 1000" grows symmetrically. + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + GlassTitlePill( + text = stringResource(R.string.viewer_page_of, currentPageIndex + 1, safePageCount), + backdrop = contentBackdrop, + onClick = { showPageJumpDialog = true }, + // Transparent, not omitted: omitting falls back to the theme's 0.35 tint, + // and this pill sits between two circles that paint nothing at all, so any + // tint at all makes it read as a slab bolted between two pieces of glass. + // `drawRect(Color.Transparent)` is the same no-op `LiquidIconButton` + // performs when it is given no `surfaceColor`. + surfaceColor = Color.Transparent, + contentColor = topFg, + // The enclosing AnimatedVisibility already slides and fades the whole bar + // in every time the chrome returns; the pill's own spring would stack on + // top of that and read as a stutter. Same call the find bar makes. + animateIn = false + ) + } + LiquidIconButton( + onClick = { + showFindBar = !showFindBar + if (showFindBar) viewModel.triggerOcrForAllPages(context) + else { focusManager.clearFocus(); viewModel.clearSearch(); findQuery = "" } + }, + backdrop = contentBackdrop + ) { + Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), topFg) } - LiquidGlassTopBar( - title = stringResource(R.string.viewer_page_of, state.currentPage + 1, safePageCount), - backdrop = backdrop, - uiSensor = uiSensor, - modifier = Modifier - .weight(1f) - .clickable { showPageJumpDialog = true }, - titleFontSize = 12.sp, - fontWeight = FontWeight.Medium - ) - - LiquidButton(onClick = { - val t = (pagerState.currentPage - 1).coerceAtLeast(0) - if (t != pagerState.currentPage) pagerScope.launch { pagerState.animateScrollToPage(t) } - lastInteractionAtMs = System.currentTimeMillis() - }, backdrop = backdrop) { BasicText(stringResource(R.string.viewer_prev), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - LiquidButton(onClick = { - val t = (pagerState.currentPage + 1).coerceAtMost(safePageCount - 1) - if (t != pagerState.currentPage) pagerScope.launch { pagerState.animateScrollToPage(t) } - lastInteractionAtMs = System.currentTimeMillis() - }, backdrop = backdrop) { BasicText(stringResource(R.string.viewer_next), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } } } + // Bottom toolbar AnimatedVisibility( - visible = controlsVisible, - enter = fadeIn(tween(200)) + androidx.compose.animation.slideInVertically { it / 2 }, - exit = fadeOut(tween(150)) + androidx.compose.animation.slideOutVertically { it / 2 }, + visible = controlsVisible, + enter = fadeIn(tween(200)) + slideInVertically { it / 2 }, + exit = fadeOut(tween(150)) + slideOutVertically { it / 2 }, modifier = Modifier.align(Alignment.BottomCenter) ) { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - val selectedTextCount = state.selectedOcrBlockIdsByPage[state.currentPage]?.size ?: 0 - val currentSelectedIds = state.selectedOcrBlockIdsByPage[state.currentPage].orEmpty() - val hasEdits = annotationsByPage.values.any { it.isNotEmpty() } - val toolbarMode = when { - showFindBar -> ViewerToolbarMode.Search - showSignaturePad -> ViewerToolbarMode.Signature - activeImageId != null -> ViewerToolbarMode.Image - drawingToolActive -> ViewerToolbarMode.Drawing - activeTool == PdfEditTool.SelectText || selectedTextCount > 0 -> ViewerToolbarMode.Selection - activeTool == PdfEditTool.Eraser -> ViewerToolbarMode.Eraser - else -> ViewerToolbarMode.Main - } - - AnimatedVisibility( - visible = !showFindBar && !showSignaturePad && activeImageId == null, - enter = fadeIn(tween(220)) + expandVertically( - animationSpec = tween(260), - expandFrom = Alignment.Bottom - ) + scaleIn(initialScale = 0.96f), - exit = fadeOut(tween(150)) + shrinkVertically( - animationSpec = tween(180), - shrinkTowards = Alignment.Bottom - ) + scaleOut(targetScale = 0.96f) - ) { - AnimatedContent( - targetState = toolbarMode, - transitionSpec = { - (fadeIn(tween(180)) + expandVertically( - animationSpec = tween(220), - expandFrom = Alignment.Bottom - ) + scaleIn(initialScale = 0.97f)).togetherWith( - fadeOut(tween(120)) + shrinkVertically( - animationSpec = tween(150), - shrinkTowards = Alignment.Bottom - ) + scaleOut(targetScale = 0.97f) - ) - }, - label = "viewerToolMorph" - ) { _ -> - Row( - Modifier - .fillMaxWidth() - .liquidGlassPanel(backdrop, uiSensor) - .padding(horizontal = 12.dp, vertical = 10.dp) - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - LiquidButton( - onClick = { activeTool = if (drawingToolActive) PdfEditTool.None else PdfEditTool.Draw }, - backdrop = backdrop, - tint = if (drawingToolActive) Color(0xFF00BCD4) else Color.Transparent - ) { BasicText(stringResource(R.string.viewer_draw_tools), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - - LiquidButton( - onClick = { activeTool = if (activeTool == PdfEditTool.SelectText) PdfEditTool.None else PdfEditTool.SelectText }, - backdrop = backdrop, - tint = if (activeTool == PdfEditTool.SelectText) Color(0xFFAB47BC) else Color.Transparent - ) { - BasicText( - if (selectedTextCount > 0) stringResource(R.string.viewer_ocr, selectedTextCount) else stringResource(R.string.viewer_select_text), - style = TextStyle(Color.White, 12.sp, FontWeight.Medium) - ) - } - - LiquidButton( - onClick = { imagePickerLauncher.launch("image/*") }, - backdrop = backdrop, - tint = if (activeTool == PdfEditTool.Image) Color(0xFF1976D2) else Color.Transparent - ) { BasicText(stringResource(R.string.viewer_add_image), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - - LiquidButton( - onClick = { activeTool = if (activeTool == PdfEditTool.Eraser) PdfEditTool.None else PdfEditTool.Eraser }, - backdrop = backdrop, - tint = if (activeTool == PdfEditTool.Eraser) Color(0xFFEF5350) else Color.Transparent - ) { BasicText(stringResource(R.string.viewer_eraser), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - - LiquidButton( - onClick = { showSignaturePad = true }, - backdrop = backdrop, - tint = Color(0xFF5E35B1) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Rounded.Gesture, null, Modifier.size(14.dp), Color.White) - BasicText(stringResource(R.string.viewer_sign), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) - } - } - - LiquidButton( - onClick = { - showFindBar = !showFindBar - if (showFindBar) viewModel.triggerOcrForAllPages(context) - else { - focusManager.clearFocus() - viewModel.clearSearch() - findQuery = "" - } - }, - backdrop = backdrop, - tint = if (showFindBar) Color(0xFF0288D1) else Color.Transparent - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Rounded.Search, null, Modifier.size(14.dp), Color.White) - BasicText(stringResource(R.string.viewer_find), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) - } - } - - if (drawingToolActive || activeTool == PdfEditTool.SelectText || - activeTool == PdfEditTool.Image || activeTool == PdfEditTool.Eraser) { - LiquidIconButton( - onClick = { - activeTool = PdfEditTool.None - activeImageId = null - viewModel.clearOcrSelection(state.currentPage) - }, - backdrop = backdrop, - tint = Color(0xFFEF5350), - modifier = Modifier.size(32.dp) - ) { - CloseCrossIcon(Modifier.size(14.dp), Color.White) - } - } - - if (zoomScale > 1.01f) { - LiquidButton( - onClick = { - scope.launch { - animateZoomPan( - targetZoom = 1f, - targetPan = Offset.Zero, - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ) - } - lastInteractionAtMs = System.currentTimeMillis() - }, - backdrop = backdrop - ) { - BasicText( - stringResource( - R.string.viewer_reset_zoom, - (zoomScale * 100 + 0.5f).toInt() - ), - style = TextStyle(Color.White, 12.sp, FontWeight.Medium) - ) - } - } - - if (hasEdits && !state.isExporting) { - LiquidButton( - onClick = { showSaveDialog = true }, - backdrop = backdrop, - tint = Color(0xFF1976D2) - ) { BasicText(stringResource(R.string.viewer_save_edits), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - } - } + val activeItem = activeImageLoc()?.third + + PdfViewerBottomToolbar( + activeTool = activeTool, + drawingToolActive = drawingToolActive, + showFindBar = showFindBar, + showSignaturePad = showSignaturePad, + activeImageId = activeImageId, + currentColor = currentColor, + currentColorLong = currentColorLong, + currentStrokeWidth = currentStrokeWidth, + zoomScale = scale, + hasEdits = annotationsByPage.values.any { it.isNotEmpty() }, + isExporting = state.isExporting, + exportError = state.exportError, + exportMessage = state.exportMessage, + lastExportedUri = state.lastExportedUri, + selectedTextCount = selectedTextRanges(currentPageIndex).size, + currentSelectedIds = state.selectedOcrBlockIdsByPage[currentPageIndex].orEmpty(), + activeIsSignature = activeItem?.isSignature == true, + // Undo is history-driven, not page-driven. Two earlier attempts keyed it to a + // page index β€” first `state.currentPage` (async, lagged behind the scroll), then + // `listState.firstVisibleItemIndex` (the first *partially* visible page, which + // while you draw is usually a sliver of the PREVIOUS page). Both removed from the + // wrong, usually empty, list. There is no page index that reliably means "what + // the user just did", so the viewer records each addition instead. + canUndo = undoStack.isNotEmpty() || annotationsByPage.any { it.value.isNotEmpty() }, + onUndo = { undoLastEdit(); lastInteractionAtMs = System.currentTimeMillis() }, + onClearPage = { clearVisiblePage(); lastInteractionAtMs = System.currentTimeMillis() }, + onSetActiveTool = { activeTool = it; if (it == PdfEditTool.None) activeImageId = null; selectedAnnoPage = null; selectedAnnoIndex = -1; viewModel.clearOcrSelection(currentPageIndex) }, + onToggleFindBar = { + showFindBar = !showFindBar + if (showFindBar) viewModel.triggerOcrForAllPages(context) + else { focusManager.clearFocus(); viewModel.clearSearch(); findQuery = "" } + }, + onShowSignaturePad = { showSignaturePad = true }, + onPickImage = { activeImageId = null; imagePickerLauncher.launch("image/*") }, + onResetZoom = { scope.launch { animateZoomPan(1f, Offset.Zero) } + lastInteractionAtMs = System.currentTimeMillis() }, + onShowSaveDialog = { showSaveDialog = true }, + onImageDone = { activeImageId = null; activeTool = PdfEditTool.None }, + onReplaceImage = { + if (activeItem?.isSignature == true) showSignaturePad = true + else imagePickerLauncher.launch("image/*") + }, + onDeleteImage = { + activeImageLoc()?.let { (pg, idx, _) -> getPageMarks(pg).removeAt(idx) } + activeImageId = null; activeTool = PdfEditTool.None + }, + onSelectAllText = { + val ids = state.ocrBlocksByPage[currentPageIndex].orEmpty().map { it.id }.toSet() + if (ids.isNotEmpty()) viewModel.selectOcrBlocks(currentPageIndex, ids, false) + }, + onCopyText = { + viewModel.getSelectedOcrText(currentPageIndex).takeIf { it.isNotBlank() } + ?.let { clipboard.setText(AnnotatedString(it)) } + }, + onHighlightSelected = { + val m = getPageMarks(currentPageIndex) + selectedTextRanges(currentPageIndex).forEach { range -> + if (!m.any { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end }) + m.add(PdfMarkup.TextBlockHighlightMarkup(range.blockId, Color(currentColorLong), 0.38f, range.start, range.end)) } - } - - val showDrawTools = drawingToolActive - val showOcrTools = activeTool == PdfEditTool.SelectText || selectedTextCount > 0 - val showImageTools = activeTool == PdfEditTool.Image && activeImageId != null - - AnimatedVisibility( - visible = (showDrawTools || showOcrTools || showImageTools) && - !showFindBar && !showSignaturePad, - enter = fadeIn(tween(220)) + expandVertically( - animationSpec = tween(260), - expandFrom = Alignment.Bottom - ) + scaleIn(initialScale = 0.97f), - exit = fadeOut(tween(150)) + shrinkVertically( - animationSpec = tween(180), - shrinkTowards = Alignment.Bottom - ) + scaleOut(targetScale = 0.97f) - ) { - Column( - Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (showDrawTools) { - listOf( - PdfEditTool.Draw to stringResource(R.string.viewer_pen), - PdfEditTool.Highlight to stringResource(R.string.viewer_highlight), - PdfEditTool.Rect to stringResource(R.string.viewer_rect), - PdfEditTool.Ellipse to stringResource(R.string.viewer_oval), - PdfEditTool.Line to stringResource(R.string.viewer_line), - PdfEditTool.Arrow to stringResource(R.string.viewer_arrow) - ).forEach { (tool, label) -> - LiquidButton( - onClick = { activeTool = tool }, - backdrop = backdrop, - surfaceColor = if (activeTool == tool) - currentColor.copy(0.2f) - else Color.White.copy(0.08f) - ) { BasicText(label, style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - } - LiquidButton( - onClick = { - getPageMarks(state.currentPage).let { if (it.isNotEmpty()) it.removeAt(it.lastIndex) } - }, - backdrop = backdrop - ) { BasicText(stringResource(R.string.viewer_undo), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - LiquidButton( - onClick = { getPageMarks(state.currentPage).clear() }, - backdrop = backdrop - ) { BasicText(stringResource(R.string.viewer_clear), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - } else if (showOcrTools) { - LiquidButton(onClick = { - val ids = state.ocrBlocksByPage[state.currentPage].orEmpty().map { it.id }.toSet() - if (ids.isNotEmpty()) viewModel.selectOcrBlocks(state.currentPage, ids, false) - }, backdrop = backdrop) { BasicText(stringResource(R.string.viewer_select_all), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - - LiquidButton( - onClick = { - viewModel.getSelectedOcrText(state.currentPage) - .takeIf { it.isNotBlank() } - ?.let { clipboard.setText(AnnotatedString(it)) } - }, - backdrop = backdrop, tint = Color(0xFF7E57C2) - ) { BasicText(stringResource(R.string.copy), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - - LiquidButton( - onClick = { - val m = getPageMarks(state.currentPage) - currentSelectedIds.forEach { id -> - if (!m.any { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == id }) - m.add(PdfMarkup.TextBlockHighlightMarkup(id, Color(currentColorLong), 0.30f)) - } - }, - backdrop = backdrop, tint = Color(0xFFFFB300) - ) { BasicText(stringResource(R.string.viewer_highlight), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - - LiquidButton( - onClick = { - val m = getPageMarks(state.currentPage) - currentSelectedIds.forEach { id -> - if (!m.any { it is PdfMarkup.TextBlockLineMarkup && it.blockId == id && !it.strikeThrough }) - m.add(PdfMarkup.TextBlockLineMarkup(id, Color(currentColorLong), 3f, 1f, false)) - } - }, - backdrop = backdrop, tint = Color(0xFF4CAF50) - ) { BasicText(stringResource(R.string.viewer_underline), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - - LiquidButton( - onClick = { - val m = getPageMarks(state.currentPage) - currentSelectedIds.forEach { id -> - if (!m.any { it is PdfMarkup.TextBlockLineMarkup && it.blockId == id && it.strikeThrough }) - m.add(PdfMarkup.TextBlockLineMarkup(id, Color(currentColorLong), 3f, 1f, true)) - } - }, - backdrop = backdrop, tint = Color(0xFFEF5350) - ) { BasicText(stringResource(R.string.viewer_strike), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - - LiquidButton( - onClick = { viewModel.clearOcrSelection(state.currentPage) }, - backdrop = backdrop - ) { BasicText(stringResource(R.string.viewer_clear), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - } else if (showImageTools) { - val activeItem = getPageMarks(state.currentPage) - .firstOrNull { it is PdfMarkup.ImageMarkup && it.id == activeImageId } as? PdfMarkup.ImageMarkup - val isSignature = activeItem?.isSignature == true - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - LiquidButton( - onClick = { - activeImageId = null - activeTool = PdfEditTool.None - }, - backdrop = backdrop, - tint = Color(0xFF00C853) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp) - ) { - Icon(Icons.Rounded.Check, null, Modifier.size(18.dp), Color.White) - BasicText(stringResource(R.string.viewer_done), style = TextStyle(Color.White, 14.sp, FontWeight.Bold)) - } - } - - LiquidButton( - onClick = { - if (isSignature) { - showSignaturePad = true - } else { - imagePickerLauncher.launch("image/*") - } - }, - backdrop = backdrop, - tint = Color(0xFF1976D2) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp) - ) { - Icon(Icons.Rounded.SwapHoriz, null, Modifier.size(18.dp), Color.White) - BasicText(if (isSignature) stringResource(R.string.viewer_new_sign) else stringResource(R.string.viewer_replace), style = TextStyle(Color.White, 13.sp, FontWeight.Medium)) - } - } - - LiquidButton( - onClick = { - val m = getPageMarks(state.currentPage) - val idx = m.indexOfLast { it is PdfMarkup.ImageMarkup && it.id == activeImageId } - if (idx >= 0) m.removeAt(idx) - activeImageId = null - activeTool = PdfEditTool.None - }, - backdrop = backdrop, - tint = Color(0xFFEF5350) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp) - ) { - Icon(Icons.Rounded.Delete, null, Modifier.size(18.dp), Color.White) - BasicText(stringResource(R.string.delete), style = TextStyle(Color.White, 13.sp, FontWeight.Medium)) - } - } - } - } - } - } - - if (showDrawTools) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - BasicText(stringResource(R.string.viewer_size), style = TextStyle(Color.White.copy(0.7f), 12.sp)) - listOf("S" to 3f, "M" to 6f, "L" to 11f, "XL" to 18f).forEach { (label, w) -> - val sel = currentStrokeWidth == w - LiquidButton( - onClick = { currentStrokeWidth = w }, - backdrop = backdrop, - surfaceColor = if (sel) currentColor.copy(0.30f) else Color.White.copy(0.08f) - ) { BasicText(label, style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } - } - } - } - - Row( - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - listOf( - 0xFF00BCD4L, 0xFFFFB300L, 0xFF4CAF50L, 0xFFEF5350L, - 0xFF42A5F5L, 0xFFAB47BCL, 0xFF26A69AL, 0xFFE0E0E0L - ).forEach { cl -> - val sel = currentColorLong == cl - Box( - Modifier - .size(26.dp) - .clip(CircleShape) - .background(Color(cl)) - .clickable { - currentColorLong = cl - if (showImageTools) { - val marks = getPageMarks(state.currentPage) - val idx = marks.indexOfLast { - it is PdfMarkup.ImageMarkup && - it.id == activeImageId && - it.isSignature - } - val signature = marks.getOrNull(idx) as? PdfMarkup.ImageMarkup - if (signature != null) { - marks[idx] = signature.copy( - bitmap = recolorSignatureBitmap( - signature.bitmap, - Color(cl).toArgb() - ) - ) - } - } - } - .border(if (sel) 2.dp else 0.dp, - if (sel) Color.White else Color.Transparent, - CircleShape) - ) { - if (sel) Box( - Modifier - .size(8.dp) - .clip(CircleShape) - .background(Color.White) - .align(Alignment.Center) - ) - } - } - } + }, + onUnderlineSelected = { + val m = getPageMarks(currentPageIndex) + selectedTextRanges(currentPageIndex).forEach { range -> + if (!m.any { it is PdfMarkup.TextBlockLineMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end && !it.strikeThrough }) + m.add(PdfMarkup.TextBlockLineMarkup(range.blockId, Color(currentColorLong), 3f, 1f, false, range.start, range.end)) } - } - - if (state.exportError != null || state.exportMessage != null || state.isExporting) { - Row( - Modifier - .fillMaxWidth() - .liquidGlassPanel(backdrop, uiSensor) - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - when { - state.isExporting -> BasicText( - stringResource(R.string.viewer_saving), - style = TextStyle(Color.White.copy(0.82f), 12.sp) - ) - state.exportError != null -> { - BasicText(state.exportError!!, style = TextStyle(Color(0xFFFF8A80), 12.sp)) - LiquidButton( - onClick = { viewModel.clearExportFeedback() }, - backdrop = backdrop - ) { - BasicText( - stringResource(R.string.dismiss), - style = TextStyle(Color.White, 11.sp, FontWeight.Medium) - ) - } - } - state.exportMessage != null -> { - BasicText(state.exportMessage!!, style = TextStyle(Color(0xFFB9F6CA), 12.sp)) - state.lastExportedUri?.let { uri -> - LiquidButton( - onClick = { viewModel.openPdf(context, uri) }, - backdrop = backdrop, - tint = Color(0xFF1976D2) - ) { - BasicText( - stringResource(R.string.open), - style = TextStyle(Color.White, 11.sp, FontWeight.Medium) - ) - } - } - } - } + }, + onStrikeSelected = { + val m = getPageMarks(currentPageIndex) + selectedTextRanges(currentPageIndex).forEach { range -> + if (!m.any { it is PdfMarkup.TextBlockLineMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end && it.strikeThrough }) + m.add(PdfMarkup.TextBlockLineMarkup(range.blockId, Color(currentColorLong), 3f, 1f, true, range.start, range.end)) } - } - - AnimatedVisibility(visible = activeImageId == null && !showFindBar && !drawingToolActive) { - LiquidButton( - onClick = { pdfPickerLauncher.launch(arrayOf("*/*")) }, - backdrop = backdrop, - tint = accent, - modifier = Modifier.fillMaxWidth() - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 10.dp) - ) { - Icon(Icons.Rounded.UploadFile, null, Modifier.size(16.dp), Color.White) - BasicText(stringResource(R.string.viewer_open_another), style = TextStyle(Color.White, 14.sp, fontWeight = FontWeight.Medium)) - } + }, + onClearTextSelection = { viewModel.clearOcrSelection(currentPageIndex) }, + onSetColorLong = { currentColorLong = it }, + onSetStrokeWidth = { currentStrokeWidth = it }, + onDismissExportFeedback = { viewModel.clearExportFeedback() }, + onOpenExportedFile = { state.lastExportedUri?.let { viewModel.openPdf(context, it) } }, + onOpenAnotherPdf = { pdfPickerLauncher.launch(arrayOf("*/*")) }, + onEditorOpenChanged = { editorToolsOpen = it }, + // Bumping the timestamp on RELEASE is half the fix: otherwise the hold ends and + // the effect resumes a window that is already most of the way expired, so the + // chrome blinks out a moment after the finger lifts. + onShareHoldChanged = { shareHolding = it; lastInteractionAtMs = System.currentTimeMillis() }, + // Share now opens the export chooser instead of firing a PDF straight out β€” a + // .docx used to always leave as the converted PDF, silently. The dialog lets the + // user pick the original file vs a PDF (and encrypt the PDF). See ExportShareDialog. + onShareDocument = { showShareDialog = true }, + onRecolorSignature = { cl -> + activeImageLoc()?.let { (pg, idx, sig) -> + if (sig.isSignature) getPageMarks(pg)[idx] = sig.copy(bitmap = recolorSignatureBitmap(sig.bitmap, Color(cl).toArgb())) } - } - } + }, + backdrop = contentBackdrop, + uiSensor = uiSensor, + fg = bottomFg, + fgSoft = bottomFgSoft, + glass = chromeGlass, + chip = chromeField, + docKind = com.chethan616.clearpdf.utils.docKindOf(state.fileName) + ) } } + // ── Find bar ────────────────────────────────────────────────────── AnimatedVisibility( visible = showFindBar, - enter = fadeIn(tween(220)) + expandVertically( - animationSpec = tween(280), - expandFrom = Alignment.Bottom - ) + scaleIn(initialScale = 0.96f), - exit = fadeOut(tween(150)) + shrinkVertically( - animationSpec = tween(180), - shrinkTowards = Alignment.Bottom - ) + scaleOut(targetScale = 0.96f), + enter = fadeIn(tween(220)) + expandVertically(tween(280), Alignment.Bottom) + scaleIn(initialScale = 0.96f), + exit = fadeOut(tween(150)) + shrinkVertically(tween(180), Alignment.Bottom) + scaleOut(targetScale = 0.96f), modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .windowInsetsPadding(WindowInsets.navigationBars.union(WindowInsets.ime)) .padding(horizontal = 16.dp, vertical = 12.dp) ) { - val findMatches = state.findMatches - val matchIdx = state.currentMatchIndex - val pagerRef = pagerState - - LaunchedEffect(showFindBar) { - if (showFindBar) { - kotlinx.coroutines.delay(100) - try { findFocusRequester.requestFocus() } catch (_: Exception) {} - } - } + PdfSearchBar( + query = findQuery, + matchCount = state.findMatches.size, + currentMatchIndex = state.currentMatchIndex, + focusRequester = findFocusRequester, + backdrop = contentBackdrop, + uiSensor = uiSensor, + fg = findFg, + fgSoft = findFgSoft, + surface = chromeGlass, + onQueryChange = { q -> findQuery = q; viewModel.searchText(q) }, + onPrevMatch = { viewModel.prevMatch(); lastInteractionAtMs = System.currentTimeMillis() }, + onNextMatch = { viewModel.nextMatch(); lastInteractionAtMs = System.currentTimeMillis() }, + onClose = { showFindBar = false; focusManager.clearFocus(); findQuery = ""; viewModel.clearSearch() } + ) + } - LaunchedEffect(matchIdx, findMatches) { - if (matchIdx >= 0 && findMatches.isNotEmpty()) { - val match = findMatches[matchIdx] - if (pagerRef.currentPage != match.pageIndex) { - pagerScopeForFind.launch { - pagerRef.animateScrollToPage(match.pageIndex) - } - } - } - } + // ── Page-jump popup (in-window, so it samples the liquid-glass backdrop) ── + LiquidPageJumpPopup( + visible = showPageJumpDialog, + currentPage = currentPageIndex, + pageCount = safePageCount, + backdrop = contentBackdrop, + uiSensor = uiSensor, + fg = panelFg, + fgSoft = panelFgSoft, + surface = chromePanel, + field = chromeField, + onDismiss = { showPageJumpDialog = false }, + onJumpToPage = { targetPage -> showPageJumpDialog = false; scrollToPage(targetPage) } + ) - Row( - Modifier - .fillMaxWidth() - .liquidGlassPanel(backdrop, uiSensor) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - Modifier - .weight(1f) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(0.12f)) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon(Icons.Rounded.Search, null, Modifier.size(16.dp), Color.White.copy(0.6f)) - Box(Modifier.weight(1f)) { - if (findQuery.isEmpty()) { - BasicText( - stringResource(R.string.viewer_find_hint), - style = TextStyle(Color.White.copy(0.45f), 13.sp) - ) + // ── Text / note editor (in-window, samples the real page backdrop) ── + editingAnnoId?.let { annoId -> + fun matches(m: PdfMarkup) = + (m is PdfMarkup.TextBoxMarkup && m.id == annoId) || (m is PdfMarkup.NoteMarkup && m.id == annoId) + AnnotationEditorDialog( + isNote = editingAnnoIsNote, + initialText = annotationDraft, + initialColor = editingAnnoColor, + backdrop = contentBackdrop, + uiSensor = uiSensor, + fg = panelFg, + fgSoft = panelFgSoft, + surface = chromePanel, + field = chromeField, + onDismiss = { + getPageMarks(editingAnnoPage).removeAll { m -> matches(m) && + ((m is PdfMarkup.TextBoxMarkup && m.text.isBlank()) || (m is PdfMarkup.NoteMarkup && m.text.isBlank())) } + editingAnnoId = null + }, + onDelete = { + getPageMarks(editingAnnoPage).removeAll { matches(it) } + editingAnnoId = null + }, + onSave = { newText, newColor -> + val list = getPageMarks(editingAnnoPage) + val idx = list.indexOfFirst { matches(it) } + if (idx >= 0) { + if (newText.isBlank() && list[idx] is PdfMarkup.TextBoxMarkup) list.removeAt(idx) + else list[idx] = when (val m = list[idx]) { + is PdfMarkup.TextBoxMarkup -> m.copy(text = newText, color = newColor) + is PdfMarkup.NoteMarkup -> m.copy(text = newText, color = newColor) + else -> m } - BasicTextField( - value = findQuery, - onValueChange = { q -> - findQuery = q - viewModel.searchText(q) - }, - textStyle = TextStyle(Color.White, 13.sp), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .focusRequester(findFocusRequester) - ) } + editingAnnoId = null } + ) + } - if (findMatches.isNotEmpty()) { - BasicText( - "${matchIdx + 1}/${findMatches.size}", - style = TextStyle(Color.White.copy(0.85f), 11.sp, FontWeight.SemiBold) - ) - } else if (findQuery.isNotBlank()) { - BasicText( - stringResource(R.string.viewer_find_no_results), - style = TextStyle(Color(0xFFFF8A80), 11.sp, FontWeight.Medium) - ) - } - - LiquidIconButton( - onClick = { viewModel.prevMatch(); lastInteractionAtMs = System.currentTimeMillis() }, - backdrop = backdrop, - surfaceColor = Color.White.copy(0.1f), - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Rounded.KeyboardArrowUp, stringResource(R.string.previous), Modifier.size(18.dp), Color.White) - } - LiquidIconButton( - onClick = { viewModel.nextMatch(); lastInteractionAtMs = System.currentTimeMillis() }, - backdrop = backdrop, - surfaceColor = Color.White.copy(0.1f), - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Rounded.KeyboardArrowDown, stringResource(R.string.next), Modifier.size(18.dp), Color.White) - } - - LiquidIconButton( - onClick = { - showFindBar = false - focusManager.clearFocus() - findQuery = "" - viewModel.clearSearch() + // ── Shape editor (recolour / delete a placed rect / oval / line / arrow / stroke) ── + editingShapePage?.let { shapePage -> + val list = getPageMarks(shapePage) + val shape = list.getOrNull(editingShapeIndex) + if (shape != null && shape.isShape()) { + ShapeEditorPopup( + initialColor = shape.shapeColor(), + backdrop = contentBackdrop, + uiSensor = uiSensor, + fg = panelFg, + fgSoft = panelFgSoft, + surface = chromePanel, + field = chromeField, + onColorChange = { c -> + val cur = list.getOrNull(editingShapeIndex) + if (cur != null && cur.isShape()) list[editingShapeIndex] = cur.recolored(c) + lastInteractionAtMs = System.currentTimeMillis() }, - backdrop = backdrop, - surfaceColor = Color(0xFFEF5350).copy(0.18f), - modifier = Modifier.size(32.dp) - ) { - CloseCrossIcon(Modifier.size(12.dp), Color.White) - } + onDelete = { + if (editingShapeIndex in list.indices) list.removeAt(editingShapeIndex) + editingShapePage = null; editingShapeIndex = -1 + }, + onDismiss = { editingShapePage = null; editingShapeIndex = -1 } + ) + } else { + editingShapePage = null; editingShapeIndex = -1 } } - } - if (showSaveDialog) { - LiquidSaveDialog( - initialFileName = state.document?.name - ?.substringBeforeLast('.') - ?.let { "${it}_Edited" } ?: "Document", - backdrop = backdrop, + // ── Save Document β€” REAL in-window glass (samples the live page) ── + LiquidSaveSheet( + visible = showSaveDialog, + initialFileName = state.document?.name?.substringBeforeLast('.')?.let { "${it}_Edited" } ?: "Document", + backdrop = contentBackdrop, uiSensor = uiSensor, + fg = panelFg, + fgSoft = panelFgSoft, + surface = chromePanel, + field = chromeField, onDismiss = { showSaveDialog = false }, onSave = { fileName, overrideUri -> showSaveDialog = false - val overlays = buildExportOverlays( - annotationsByPage, state.ocrBlocksByPage, pageCanvasSizes, pageBitmapSizes - ) - if (overlays.isNotEmpty()) - viewModel.exportEditedPdf(context, overlays, fileName, overrideUri) + val overlays = buildExportOverlays(annotationsByPage, state.ocrBlocksByPage, pageCanvasSizes, pageBitmapSizes) + if (overlays.isNotEmpty()) viewModel.exportEditedPdf(context, overlays, fileName, overrideUri) } ) - } - if (showSignaturePad) { - SignaturePadDialog( - backdrop = backdrop, - onDismiss = { showSignaturePad = false }, - onSignatureCaptured = { bmp -> - showSignaturePad = false - runCatching { - val safeBmp = if (bmp.isRecycled) null else if (bmp.config == Bitmap.Config.HARDWARE || !bmp.isMutable) bmp.copy(Bitmap.Config.ARGB_8888, true) else bmp - if (safeBmp != null && safeBmp.width > 0 && safeBmp.height > 0) { - val page = state.currentPage - val marks = getPageMarks(page) - val existingIdx = marks.indexOfLast { it is PdfMarkup.ImageMarkup && it.id == activeImageId } - if (existingIdx >= 0 && (marks[existingIdx] as PdfMarkup.ImageMarkup).isSignature) { - val existing = marks[existingIdx] as PdfMarkup.ImageMarkup - val ratio = safeBmp.height.toFloat() / safeBmp.width.toFloat().coerceAtLeast(1f) - val currentWidth = (existing.end.x - existing.start.x).let { kotlin.math.abs(it) }.coerceAtLeast(20f) - val newHeight = currentWidth * ratio - marks[existingIdx] = existing.copy( - bitmap = safeBmp, - end = Offset(existing.end.x, existing.start.y + newHeight), - isSignature = true - ) - } else { - val rawCs = pageCanvasSizes[page] - val cs = if (rawCs != null && rawCs.width > 50f && rawCs.height > 50f) rawCs else Size(1000f, 1400f) - val bs = pageBitmapSizes[page] ?: cs - val frame = fitBitmapRect(cs, bs.width, bs.height) - val maxW = (if (frame.width > 50f) frame.width else cs.width) * 0.45f - val ratio = safeBmp.height.toFloat() / safeBmp.width.toFloat().coerceAtLeast(1f) - val w = maxW.coerceAtLeast(100f) - val h = w * ratio - val center = if (frame.width > 50f) frame.center else Offset(cs.width / 2f, cs.height / 2f) - val id = System.nanoTime() - marks.add( - PdfMarkup.ImageMarkup( - id = id, - bitmap = safeBmp, - start = Offset(center.x - w / 2f, center.y - h / 2f), - end = Offset(center.x + w / 2f, center.y + h / 2f), - isSignature = true - ) + // ── Share / export chooser ── + // Original vs PDF (with optional encryption). `originalExt` is null for a plain PDF, which + // collapses the dialog to just the encrypt toggle. + val shareExt = state.fileName.substringAfterLast('.', "").uppercase() + ExportShareDialog( + visible = showShareDialog, + originalExt = if (shareExt.isNotBlank() && shareExt != "PDF") shareExt else null, + backdrop = contentBackdrop, + uiSensor = uiSensor, + fg = panelFg, + fgSoft = panelFgSoft, + surface = chromePanel, + field = chromeField, + onDismiss = { showShareDialog = false }, + onShare = { format, encrypt, password -> + showShareDialog = false + viewerScope.launch { + // All file work off the main thread: copy/encrypt, then hand a FileProvider uri to + // the chooser. Everything lands in cacheDir/shared, which the app FileProvider + // serves (file_paths.xml cache-path "/"). + val payload = withContext(Dispatchers.IO) { + runCatching { + fun wrap(u: android.net.Uri): android.net.Uri = + if (u.scheme == "file") + androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", java.io.File(u.path!!) + ) + else u + val shareDir = java.io.File(context.cacheDir, "shared").apply { mkdirs() } + when (format) { + ShareFormat.ORIGINAL -> state.originalUri?.let { orig -> + // Mirror the original into our own storage so the target app can + // actually read it β€” a SAF uri from another provider can't be + // re-granted to a third app. + val safeName = state.fileName.ifBlank { "document.$shareExt" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + val out = java.io.File(shareDir, "${System.currentTimeMillis()}_$safeName") + context.contentResolver.openInputStream(orig)?.use { input -> + out.outputStream().use { input.copyTo(it) } + } ?: return@runCatching null + androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", out + ) to (context.contentResolver.getType(orig) ?: "application/octet-stream") + } + ShareFormat.PDF -> state.document?.uri?.let { pdf -> + if (encrypt && password.isNotBlank()) { + val out = java.io.File(shareDir, "protected_${System.currentTimeMillis()}.pdf") + val outUri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", out + ) + com.kyant.pdfcore.security.PdfSecurityService.encryptToUri(context, pdf, outUri, password) + outUri to "application/pdf" + } else { + wrap(pdf) to "application/pdf" + } + } + } + }.getOrNull() + } + if (payload != null) { + val (shareUri, mime) = payload + val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = mime + putExtra(android.content.Intent.EXTRA_STREAM, shareUri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + runCatching { + context.startActivity( + android.content.Intent.createChooser(send, null) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) ) - activeImageId = id - activeTool = PdfEditTool.Image } } } } ) - } - if (showPageJumpDialog) { - LiquidPageJumpDialog( - currentPage = pagerState.currentPage, - pageCount = safePageCount, - backdrop = backdrop, - uiSensor = uiSensor, - onDismiss = { showPageJumpDialog = false }, - onJumpToPage = { targetPage -> - showPageJumpDialog = false - pagerScope.launch { pagerState.animateScrollToPage(targetPage) } + // ── Opening curtain ──────────────────────────────────────────────── + // The very same dark curtain the no-document branch shows while a handed-in file loads, now + // held over the freshly-loaded viewer until page 1 has actually rendered β€” so the reader is + // never flashed a blank white page card. Because both branches paint the identical opaque + // fill and spinner, the hand-off between them is invisible; only when the first bitmap arrives + // does this fade away, letting the real document appear underneath. That is what makes tapping + // a recent read as "wait a beat, then the PDF fades in" instead of a hard cut to an empty page. + val firstPageRendered = state.pageBitmaps.getOrNull(0) != null + var revealDocument by remember { mutableStateOf(false) } + LaunchedEffect(firstPageRendered) { + if (firstPageRendered && !revealDocument) { + delay(140) // let the page paint a frame before we lift the curtain + revealDocument = true } - ) - } -} - -private enum class ScrollOrientation { Vertical, Horizontal } - -private enum class PdfEditTool { None, Draw, Highlight, Rect, Ellipse, Line, Arrow, SelectText, Image, Eraser } - -/** The large bottom control surface follows the user's current intent. */ -private enum class ViewerToolbarMode { Main, Drawing, Selection, Image, Eraser, Search, Signature } - -@Composable -private fun PageScrubber( - currentPage: Int, - pageCount: Int, - pageBitmaps: List, - backdrop: com.kyant.backdrop.backdrops.LayerBackdrop, - uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - onPageChange: (Int) -> Unit, - onPageScrubbing: (Int) -> Unit, - modifier: Modifier = Modifier -) { - val isDarkMode = LocalIsDarkMode.current - val accent = Color(0xFF0088FF) - - var isDragging by remember { mutableStateOf(false) } - var isHoveredOrActive by remember { mutableStateOf(false) } - var dragPage by remember { mutableIntStateOf(currentPage) } - var lastTouchTime by remember { mutableLongStateOf(System.currentTimeMillis()) } - - LaunchedEffect(currentPage) { - if (!isDragging) { - dragPage = currentPage - isHoveredOrActive = true - lastTouchTime = System.currentTimeMillis() } - } - - LaunchedEffect(lastTouchTime, isDragging) { - if (!isDragging) { - delay(2200) - isHoveredOrActive = false - } - } - - LaunchedEffect(dragPage, isDragging) { - if (isDragging) { - onPageScrubbing(dragPage) + AnimatedVisibility( + visible = !revealDocument, + exit = fadeOut(tween(420, easing = androidx.compose.animation.core.FastOutSlowInEasing)), + modifier = Modifier.fillMaxSize() + ) { + ViewerLoadingCurtain(isLight = isLight) } } - val targetFraction = (currentPage.toFloat() / (pageCount - 1).coerceAtLeast(1)).coerceIn(0f, 1f) - val animatedFraction by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isDragging) (dragPage.toFloat() / (pageCount - 1).coerceAtLeast(1)).coerceIn(0f, 1f) else targetFraction, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy), - label = "thumbFraction" - ) - - val scrubberHeightDp = 182.dp - - val trackWidthDp by androidx.compose.animation.core.animateDpAsState( - targetValue = if (isDragging) 12.dp else 6.dp, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "trackWidth" - ) + // ── Dialogs ─────────────────────────────────────────────────────────── - Box( - modifier = modifier - .padding(end = 4.dp) - .width(48.dp) - .height(scrubberHeightDp + 36.dp) - .liquidGlassPanel(backdrop, uiSensor) - .padding(horizontal = 8.dp, vertical = 8.dp), - contentAlignment = Alignment.CenterEnd - ) { - BasicText( - text = "${currentPage + 1}", - style = TextStyle(color = accent, fontSize = 9.sp, fontWeight = FontWeight.Bold), - modifier = Modifier.align(Alignment.TopCenter) - ) - Box( - modifier = Modifier - .width(40.dp) - .height(scrubberHeightDp) - .pointerInput(pageCount) { - detectDragGestures( - onDragStart = { offset -> - isDragging = true - isHoveredOrActive = true - val totalPx = size.height.toFloat() - val frac = (offset.y / totalPx).coerceIn(0f, 1f) - val target = (frac * (pageCount - 1)).roundToInt() - dragPage = target - onPageScrubbing(target) - onPageChange(target) - }, - onDragEnd = { - isDragging = false - lastTouchTime = System.currentTimeMillis() - onPageChange(dragPage) - }, - onDragCancel = { - isDragging = false - lastTouchTime = System.currentTimeMillis() - }, - onDrag = { change, _ -> - isHoveredOrActive = true - val totalPx = size.height.toFloat() - val frac = (change.position.y / totalPx).coerceIn(0f, 1f) - val target = (frac * (pageCount - 1)).roundToInt() - if (target != dragPage) { - dragPage = target - onPageScrubbing(target) - onPageChange(target) - } - } - ) - } - .pointerInput(pageCount) { - detectTapGestures { offset -> - isHoveredOrActive = true - lastTouchTime = System.currentTimeMillis() - val totalPx = size.height.toFloat() - val frac = (offset.y / totalPx).coerceIn(0f, 1f) - val target = (frac * (pageCount - 1)).roundToInt() - dragPage = target - onPageScrubbing(target) - onPageChange(target) + if (showSignaturePad) { + SignaturePadDialog( + backdrop = backdrop, + onDismiss = { showSignaturePad = false }, + onSignatureCaptured = { bmp -> + showSignaturePad = false + runCatching { + val safeBmp = when { + bmp.isRecycled -> null + bmp.config == Bitmap.Config.HARDWARE || !bmp.isMutable -> bmp.copy(Bitmap.Config.ARGB_8888, true) + else -> bmp } - }, - contentAlignment = Alignment.Center - ) { - Column( - Modifier - .fillMaxWidth() - .height(scrubberHeightDp) - .padding(vertical = 8.dp), - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally - ) { - repeat(pageCount.coerceAtMost(16)) { - Box( - Modifier - .fillMaxWidth() - .height(2.dp) - .clip(RoundedCornerShape(4.dp)) - .background(if (isDarkMode) Color.White.copy(0.10f) else Color.Black.copy(0.08f)) - ) - } - } - Box( - modifier = Modifier - .width(trackWidthDp) - .height(scrubberHeightDp) - .clip(RoundedCornerShape(50)) - .background(if (isDarkMode) Color.White.copy(0.12f) else Color.Black.copy(0.08f)), - contentAlignment = Alignment.TopCenter - ) { - Box( - Modifier - .padding(top = (scrubberHeightDp * animatedFraction - 16.dp).coerceIn(0.dp, scrubberHeightDp - 32.dp)) - .size(width = if (isDragging) 16.dp else 10.dp, height = 32.dp) - .clip(RoundedCornerShape(16.dp)) - .background(if (isDragging) accent else Color.White.copy(alpha = 0.95f)) - .border( - width = 1.dp, - color = if (isDragging) Color.White.copy(0.6f) else Color.Black.copy(0.1f), - shape = RoundedCornerShape(16.dp) - ) - ) - } - } - - AnimatedVisibility( - visible = isDragging, - enter = fadeIn(animationSpec = spring(stiffness = Spring.StiffnessMedium)) + - scaleIn(initialScale = 0.85f, animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow)), - exit = fadeOut(animationSpec = spring(stiffness = Spring.StiffnessMedium)) + - scaleOut(targetScale = 0.9f) - ) { - val previewBitmap = pageBitmaps.getOrNull(dragPage) - val yOffsetDp = (scrubberHeightDp * animatedFraction - scrubberHeightDp / 2).coerceIn(-78.dp, 78.dp) - - Column( - modifier = Modifier - .align(Alignment.CenterEnd) - .offset(x = (-42).dp, y = yOffsetDp) - .width(124.dp) - .liquidGlassPanel(backdrop, uiSensor) - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - Modifier - .clip(RoundedCornerShape(50)) - .background(accent.copy(0.22f)) - .padding(horizontal = 8.dp, vertical = 3.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - BasicText( - text = stringResource(R.string.viewer_page_label), - style = TextStyle(color = accent, fontSize = 8.sp, fontWeight = FontWeight.Bold) - ) - BasicText( - text = "${dragPage + 1} / $pageCount", - style = TextStyle(color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.ExtraBold) - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .height(140.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color.Black.copy(0.45f)) - .border(1.dp, Color.White.copy(0.15f), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - if (previewBitmap != null && !previewBitmap.isRecycled) { - Image( - bitmap = previewBitmap.asImageBitmap(), - contentDescription = stringResource(R.string.preview_page, dragPage + 1), - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize().padding(4.dp) - ) - } else { - CircularProgressIndicator(color = accent, strokeWidth = 2.dp, modifier = Modifier.size(24.dp)) + if (safeBmp != null && safeBmp.width > 0 && safeBmp.height > 0) { + placeImageMarkup(safeBmp, isSignature = true) } } } - } + ) } -} - -@Composable -private fun LiquidPageJumpDialog( - currentPage: Int, - pageCount: Int, - backdrop: LayerBackdrop, - uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - onDismiss: () -> Unit, - onJumpToPage: (Int) -> Unit -) { - var targetText by remember { mutableStateOf((currentPage + 1).toString()) } - - Dialog(onDismissRequest = onDismiss) { - Column( - Modifier - .fillMaxWidth(0.85f) - .liquidGlassPanel(backdrop, uiSensor) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - BasicText( - stringResource(R.string.viewer_jump_to_page), - style = TextStyle(Color.White, 16.sp, fontWeight = FontWeight.Bold) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - BasicTextField( - value = targetText, - onValueChange = { targetText = it.filter { c -> c.isDigit() } }, - textStyle = TextStyle(Color.White, 18.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center), - singleLine = true, - modifier = Modifier - .width(70.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color.White.copy(0.12f)) - .padding(horizontal = 8.dp, vertical = 6.dp) - ) - BasicText( - "/ $pageCount", - style = TextStyle(Color.White.copy(0.7f), 16.sp, fontWeight = FontWeight.Medium) - ) - } - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End) - ) { - LiquidButton(onClick = onDismiss, backdrop = backdrop) { - BasicText(stringResource(R.string.cancel), style = TextStyle(Color.White, 13.sp)) - } - LiquidButton( - onClick = { - val p = targetText.toIntOrNull()?.minus(1)?.coerceIn(0, pageCount - 1) - if (p != null) onJumpToPage(p) - }, - backdrop = backdrop, - tint = Color(0xFF1976D2) - ) { - BasicText(stringResource(R.string.viewer_go), style = TextStyle(Color.White, 13.sp, FontWeight.Bold)) - } - } - } - } } -private fun recolorSignatureBitmap(source: Bitmap, colorArgb: Int): Bitmap { - if (source.isRecycled || source.width <= 0 || source.height <= 0) return source - - val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888) - val pixels = IntArray(source.width * source.height) - source.getPixels(pixels, 0, source.width, 0, 0, source.width, source.height) - val rgb = colorArgb and 0x00FFFFFF - for (index in pixels.indices) { - val alpha = android.graphics.Color.alpha(pixels[index]) - pixels[index] = if (alpha == 0) 0 else (alpha shl 24) or rgb - } - result.setPixels(pixels, 0, source.width, 0, 0, source.width, source.height) - return result +/** + * A plain opaque fill shown while a handed-in document loads β€” no spinner, no indicator, nothing to + * look at. It exists only so that (a) the "Open a PDF" picker never flashes before the pages arrive, + * and (b) the loaded viewer can fade in from behind it. It's identical in the no-document branch and + * the loaded overlay, so the branch swap is seamless; when the first page is ready the overlay simply + * fades out (see the AnimatedVisibility that hosts it) to reveal the PDF. That fade is the whole + * "opening" animation β€” deliberately just a dissolve. + */ +@Composable +private fun ViewerLoadingCurtain(isLight: Boolean) { + val bg = if (isLight) Color(0xFF0A0E14) else Color(0xFF05070B) + Box(Modifier.fillMaxSize().background(bg)) } -private sealed class PdfMarkup { - data class StrokeMarkup( - val points: List, - val color: Color, - val width: Float, - val alpha: Float = 1f - ) : PdfMarkup() - - data class RectMarkup( - val start: Offset, - val end: Offset, - val color: Color, - val alpha: Float = 1f, - val filled: Boolean = false - ) : PdfMarkup() - - data class OvalMarkup( - val start: Offset, - val end: Offset, - val color: Color, - val alpha: Float = 1f, - val filled: Boolean = false - ) : PdfMarkup() - - data class LineMarkup( - val start: Offset, - val end: Offset, - val color: Color, - val width: Float = 3f, - val alpha: Float = 1f, - val arrowHead: Boolean = false - ) : PdfMarkup() - - data class TextBlockHighlightMarkup( - val blockId: String, - val color: Color, - val alpha: Float = 0.30f - ) : PdfMarkup() - - data class TextBlockLineMarkup( - val blockId: String, - val color: Color, - val width: Float = 3f, - val alpha: Float = 1f, - val strikeThrough: Boolean = false - ) : PdfMarkup() - - data class ImageMarkup( - val id: Long, - val bitmap: Bitmap, - val start: Offset, - val end: Offset, - val isSignature: Boolean = false - ) : PdfMarkup() - - fun hitTest(p: Offset): Boolean = when (this) { - is StrokeMarkup -> points.any { (it - p).getDistance() <= width.coerceAtLeast(16f) } - is RectMarkup -> { - val r = Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) - r.contains(p) - } - is OvalMarkup -> { - val r = Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) - r.contains(p) - } - is LineMarkup -> { - val d = distToSegment(p, start, end) - d <= width.coerceAtLeast(16f) - } - is TextBlockHighlightMarkup -> false - is TextBlockLineMarkup -> false - is ImageMarkup -> { - val r = Rect(min(start.x, end.x), min(start.y, end.y), max(start.x, end.x), max(start.y, end.y)) - r.contains(p) +/** + * Cheap average perceptual luminance (0..1) of a page bitmap, sampled at a tiny + * resolution. Drives the viewer's adaptive chrome contrast (dark ink on light pages, + * white ink on dark pages). + */ +/** + * Perceptual luminance of a vertical slice `[fTop, fBottom]` (fractions of height) of [bitmap], + * sampled on a sparse 8x5 grid across the central 80% of the width. Deliberately uses `getPixel` + * on the live bitmap rather than `createScaledBitmap` so it is cheap enough to run per scroll frame. + * Central-width only: the far edges are usually margins that don't sit under a control. + */ +private fun regionLuminance(bitmap: Bitmap, fTop: Float, fBottom: Float): Float = runCatching { + val w = bitmap.width + val h = bitmap.height + if (w <= 0 || h <= 0) return@runCatching 0f + val y0 = (fTop.coerceIn(0f, 1f) * h).toInt().coerceIn(0, h - 1) + val y1 = (fBottom.coerceIn(0f, 1f) * h).toInt().coerceIn(y0 + 1, h) + val x0 = (w * 0.1f).toInt().coerceIn(0, w - 1) + val x1 = (w * 0.9f).toInt().coerceIn(x0 + 1, w) + val cols = 8 + val rows = 5 + var sum = 0.0 + var n = 0 + for (r in 0 until rows) { + val py = (y0 + (y1 - y0) * (r + 0.5f) / rows).toInt().coerceIn(0, h - 1) + for (c in 0 until cols) { + val px = (x0 + (x1 - x0) * (c + 0.5f) / cols).toInt().coerceIn(0, w - 1) + val p = bitmap.getPixel(px, py) + val rr = ((p shr 16) and 0xFF) / 255.0 + val gg = ((p shr 8) and 0xFF) / 255.0 + val bb = (p and 0xFF) / 255.0 + sum += 0.299 * rr + 0.587 * gg + 0.114 * bb + n++ } } -} - -private fun fitBitmapRect(canvasSize: Size, bitmapW: Float, bitmapH: Float): Rect { - if (canvasSize.width <= 0f || canvasSize.height <= 0f || bitmapW <= 0f || bitmapH <= 0f) - return Rect(0f, 0f, canvasSize.width, canvasSize.height) - - val canvasRatio = canvasSize.width / canvasSize.height - val bitmapRatio = bitmapW / bitmapH - - val (w, h) = if (canvasRatio > bitmapRatio) { - val h1 = canvasSize.height - val w1 = h1 * bitmapRatio - Pair(w1, h1) - } else { - val w1 = canvasSize.width - val h1 = w1 / bitmapRatio - Pair(w1, h1) - } - val left = (canvasSize.width - w) / 2f - val top = (canvasSize.height - h) / 2f - return Rect(left, top, left + w, top + h) -} - -private fun screenToContent( - screen: Offset, - zoomScale: Float, - panOffset: Offset, - boxCenter: Offset -): Offset { - val unpanned = screen - panOffset - val rel = unpanned - boxCenter - return (rel / zoomScale) + boxCenter -} - -private fun clampPanOffset( - pan: Offset, + if (n == 0) 0f else (sum / n).toFloat() +}.getOrDefault(0f) + +/** + * Average luminance of the document content behind a screen-space horizontal band + * `[screenTopPx, screenBottomPx]`, i.e. behind one of the floating bars. + * + * The content Box scales about a top origin with `translationY = 0`, and the LazyColumn's + * `visibleItemsInfo.offset` is already in list-local (unscaled, viewport-relative, centring-folded) + * coordinates β€” so a screen Y maps to list-local as `screenY / scale`. Each visible page item that + * intersects the band contributes its overlapping slice, weighted by how much of the band it covers. + * Returns 0 (β†’ dark β†’ white ink) when the band sits over the empty letterbox with no page under it, + * which is exactly what a single unzoomed page's centred layout should read as behind its bars. + */ +private fun bandLuminance( + layoutInfo: LazyListLayoutInfo, + pageBitmaps: List, scale: Float, - canvasSize: Size, - bitmapSize: Size -): Offset { - if (scale <= 1.01f || canvasSize.width <= 0f || canvasSize.height <= 0f) return Offset.Zero - val frame = fitBitmapRect(canvasSize, bitmapSize.width, bitmapSize.height) - val maxPanX = (frame.width * (scale - 1f) / 2f).coerceAtLeast(0f) - val maxPanY = (frame.height * (scale - 1f) / 2f).coerceAtLeast(0f) - return Offset( - pan.x.coerceIn(-maxPanX, maxPanX), - pan.y.coerceIn(-maxPanY, maxPanY) - ) -} - -private fun ocrBlockToRect(block: OcrTextBlock, frame: Rect): Rect = Rect( - frame.left + block.left * frame.width, - frame.top + block.top * frame.height, - frame.left + block.right * frame.width, - frame.top + block.bottom * frame.height -) - -private fun hitTestOcrBlock(blocks: List, contentPoint: Offset, frame: Rect): OcrTextBlock? { - val paddedPoint = contentPoint - return blocks.firstOrNull { b -> - val r = ocrBlockToRect(b, frame) - val expanded = Rect(r.left - 4f, r.top - 4f, r.right + 4f, r.bottom + 4f) - expanded.contains(paddedPoint) + screenTopPx: Float, + screenBottomPx: Float +): Float { + val s = scale.coerceAtLeast(0.01f) + val lTop = screenTopPx / s + val lBottom = screenBottomPx / s + var lum = 0.0 + var weight = 0.0 + for (item in layoutInfo.visibleItemsInfo) { + if (item.size <= 0) continue + val io = item.offset.toFloat() + val ib = io + item.size.toFloat() + val interTop = maxOf(io, lTop) + val interBottom = minOf(ib, lBottom) + val cover = interBottom - interTop + if (cover <= 0f) continue + val bmp = pageBitmaps.getOrNull(item.index) ?: continue + val fTop = (interTop - io) / item.size + val fBottom = (interBottom - io) / item.size + lum += regionLuminance(bmp, fTop, fBottom).toDouble() * cover + weight += cover.toDouble() } + return if (weight <= 0.0) 0f else (lum / weight).toFloat() } -private fun intersects(a: Rect, b: Rect): Boolean = - a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top - -private fun distToSegment(p: Offset, a: Offset, b: Offset): Float { - val l2 = (b - a).getDistanceSq() - if (l2 == 0f) return (p - a).getDistance() - val t = (((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2).coerceIn(0f, 1f) - val proj = Offset(a.x + t * (b.x - a.x), a.y + t * (b.y - a.y)) - return (p - proj).getDistance() -} - -private fun Offset.getDistanceSq(): Float = x * x + y * y - -private fun smoothPath(pts: List): Path { - val path = Path() - if (pts.isEmpty()) return path - path.moveTo(pts[0].x, pts[0].y) - if (pts.size == 1) return path - if (pts.size == 2) { - path.lineTo(pts[1].x, pts[1].y) - return path - } - for (i in 1 until pts.size - 1) { - val p0 = pts[i] - val p1 = pts[i + 1] - val midX = (p0.x + p1.x) / 2f - val midY = (p0.y + p1.y) / 2f - path.quadraticTo(p0.x, p0.y, midX, midY) +private fun averageLuminance(bitmap: Bitmap): Float = runCatching { + val w = 12 + val h = 16 + val scaled = Bitmap.createScaledBitmap(bitmap, w, h, true) + val pixels = IntArray(w * h) + scaled.getPixels(pixels, 0, w, 0, 0, w, h) + if (scaled != bitmap) scaled.recycle() + var sum = 0.0 + for (p in pixels) { + val r = ((p shr 16) and 0xFF) / 255.0 + val g = ((p shr 8) and 0xFF) / 255.0 + val b = (p and 0xFF) / 255.0 + sum += 0.299 * r + 0.587 * g + 0.114 * b } - path.lineTo(pts.last().x, pts.last().y) - return path -} - -private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawArrow( - start: Offset, end: Offset, color: Color, width: Float -) { - drawLine(color, start, end, width, cap = StrokeCap.Round) - val angle = atan2((end.y - start.y).toDouble(), (end.x - start.x).toDouble()) - val arrowLen = (width * 3.5f).coerceAtLeast(18f) - val angle1 = angle + PI - (PI / 6) - val angle2 = angle + PI + (PI / 6) - val p1 = Offset((end.x + arrowLen * cos(angle1)).toFloat(), (end.y + arrowLen * sin(angle1)).toFloat()) - val p2 = Offset((end.x + arrowLen * cos(angle2)).toFloat(), (end.y + arrowLen * sin(angle2)).toFloat()) - val path = Path().apply { - moveTo(end.x, end.y) - lineTo(p1.x, p1.y) - lineTo(p2.x, p2.y) - close() - } - drawPath(path, color) -} - -private fun buildExportOverlays( - annotationsByPage: Map>, - ocrBlocksByPage: Map>, - pageCanvasSizes: Map, - pageBitmapSizes: Map -): Map> { - val map = mutableMapOf>() - - annotationsByPage.forEach { (page, markups) -> - if (markups.isEmpty()) return@forEach - val cs = pageCanvasSizes[page] ?: return@forEach - val bs = pageBitmapSizes[page] ?: cs - if (cs.width <= 0f || cs.height <= 0f || bs.width <= 0f || bs.height <= 0f) return@forEach - - val frame = fitBitmapRect(cs, bs.width, bs.height) - - fun normPoint(p: Offset): NormalizedPoint = NormalizedPoint( - x = ((p.x - frame.left) / frame.width).coerceIn(0f, 1f), - y = ((p.y - frame.top) / frame.height).coerceIn(0f, 1f) - ) - - fun normDist(px: Float): Float = px / frame.width.coerceAtLeast(1f) - - val ocrBlocks = ocrBlocksByPage[page].orEmpty() - val list = mutableListOf() - - markups.forEach { markup -> - when (markup) { - is PdfMarkup.StrokeMarkup -> { - if (markup.points.size > 1) { - list.add( - ExportOverlay.Stroke( - points = markup.points.map { normPoint(it) }, - colorArgb = markup.color.toArgb(), - widthNorm = normDist(markup.width), - alpha = markup.alpha - ) - ) - } - } - is PdfMarkup.RectMarkup -> { - list.add( - ExportOverlay.RectShape( - start = normPoint(markup.start), - end = normPoint(markup.end), - colorArgb = markup.color.toArgb(), - alpha = markup.alpha, - filled = markup.filled - ) - ) - } - is PdfMarkup.OvalMarkup -> { - list.add( - ExportOverlay.OvalShape( - start = normPoint(markup.start), - end = normPoint(markup.end), - colorArgb = markup.color.toArgb(), - alpha = markup.alpha, - filled = markup.filled - ) - ) - } - is PdfMarkup.LineMarkup -> { - list.add( - ExportOverlay.LineShape( - start = normPoint(markup.start), - end = normPoint(markup.end), - colorArgb = markup.color.toArgb(), - widthNorm = normDist(markup.width), - alpha = markup.alpha, - arrowHead = markup.arrowHead - ) - ) - } - is PdfMarkup.TextBlockHighlightMarkup -> { - ocrBlocks.firstOrNull { it.id == markup.blockId }?.let { b -> - list.add( - ExportOverlay.RectShape( - start = NormalizedPoint(b.left, b.top), - end = NormalizedPoint(b.right, b.bottom), - colorArgb = markup.color.toArgb(), - alpha = markup.alpha, - filled = true - ) - ) - } - } - is PdfMarkup.TextBlockLineMarkup -> { - ocrBlocks.firstOrNull { it.id == markup.blockId }?.let { b -> - val y = if (markup.strikeThrough) (b.top + b.bottom) / 2f else b.bottom - (b.bottom - b.top) * 0.1f - list.add( - ExportOverlay.LineShape( - start = NormalizedPoint(b.left, y), - end = NormalizedPoint(b.right, y), - colorArgb = markup.color.toArgb(), - widthNorm = normDist(markup.width), - alpha = markup.alpha, - arrowHead = false - ) - ) - } - } - is PdfMarkup.ImageMarkup -> { - if (!markup.bitmap.isRecycled) { - list.add( - ExportOverlay.ImageStamp( - bitmap = markup.bitmap, - start = normPoint(markup.start), - end = normPoint(markup.end) - ) - ) - } - } - } - } - - if (list.isNotEmpty()) map[page] = list - } - - return map -} + (sum / pixels.size).toFloat() +}.getOrDefault(0f) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ScanDocumentScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ScanDocumentScreen.kt index 780296e..fb8c061 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ScanDocumentScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ScanDocumentScreen.kt @@ -82,7 +82,8 @@ import com.chethan616.clearpdf.R import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager import com.chethan616.clearpdf.data.model.ScanFilter import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.StarPromptEventBus @@ -198,25 +199,26 @@ fun ScanDocumentScreen( } } + GlassScreenScaffold( + backdrop = backdrop, + contentBottomPadding = 16.dp, + header = { headerBackdrop -> + // No back button here, so the pill centres against the full width. Fade only β€” the pill + // is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = stringResource(R.string.scan_title), + backdrop = headerBackdrop, + onBack = null, + modifier = Modifier.graphicsLayer { alpha = topBarAlpha } + ) + } + ) { contentPadding -> Column( Modifier .fillMaxSize() - .statusBarsPadding() - .padding(16.dp), + .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - LiquidGlassTopBar( - title = stringResource(R.string.scan_title), - backdrop = backdrop, - uiSensor = uiSensor, - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - } - ) - Column( Modifier .fillMaxWidth() @@ -571,6 +573,7 @@ fun ScanDocumentScreen( } } } + } } /** diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt index f2c62b3..0ba8e02 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt @@ -8,17 +8,15 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource 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.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -29,19 +27,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.AutoFixHigh import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Compress +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.PhotoLibrary +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.DarkMode -import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.FileCopy import androidx.compose.material.icons.rounded.FolderOpen -import androidx.compose.material.icons.rounded.FolderSpecial import androidx.compose.material.icons.rounded.HighQuality import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.LightMode import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.LightMode import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material.icons.rounded.Wallpaper import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon @@ -61,15 +62,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle 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.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource import com.chethan616.clearpdf.R import com.chethan616.clearpdf.data.repository.AppSettingsManager import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager import com.chethan616.clearpdf.data.repository.SaveLocationManager import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.LiquidSlider import com.chethan616.clearpdf.ui.components.LiquidToggle import com.chethan616.clearpdf.ui.components.liquidGlassPanel @@ -87,8 +91,13 @@ fun SettingsScreen( onDarkModeChanged: (Boolean) -> Unit = {}, themeMode: Int = 0, onThemeModeChanged: (Int) -> Unit = {}, + showWallpaper: Boolean = true, + onShowWallpaperChanged: (Boolean) -> Unit = {}, + hasCustomWallpaper: Boolean = false, + onCustomWallpaperChanged: (String?) -> Unit = {}, selectedLocale: String = "en", - onLocaleChanged: (String) -> Unit = {} + onLocaleChanged: (String) -> Unit = {}, + onReplayOnboarding: () -> Unit = {} ) { val isLight = !isDarkMode val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) @@ -102,7 +111,6 @@ fun SettingsScreen( var autoCompress by remember { mutableStateOf(AppSettingsManager.getAutoCompress(context)) } var keepOriginal by remember { mutableStateOf(AppSettingsManager.getKeepOriginal(context)) } - var notifications by remember { mutableStateOf(AppSettingsManager.getNotifications(context)) } var defaultQuality by remember { mutableFloatStateOf(AppSettingsManager.getDefaultQuality(context)) } // Debounce quality slider persistence to prevent lag @@ -131,6 +139,19 @@ fun SettingsScreen( } } + // Pick a custom background image from the gallery (persistable so it survives restarts). + val wallpaperPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null) { + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + AppSettingsManager.setCustomWallpaper(context, uri.toString()) + onCustomWallpaperChanged(uri.toString()) + } + } + var isVisible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isVisible = true @@ -215,23 +236,26 @@ fun SettingsScreen( label = "settingsPanel6OffsetY" ) + GlassScreenScaffold( + backdrop = backdrop, + header = { headerBackdrop -> + // No back button here, so the pill centres against the full width. Fade only β€” the pill + // is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = stringResource(R.string.settings_title), + backdrop = headerBackdrop, + onBack = null, + modifier = Modifier.graphicsLayer { alpha = topBarAlpha } + ) + } + ) { contentPadding -> Column( Modifier .fillMaxSize() - .statusBarsPadding() - .padding(16.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Box( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - } - ) { - LiquidGlassTopBar(title = stringResource(R.string.settings_title), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.fillMaxWidth()) - } - // ── Theme Mode Selector ── Column( Modifier @@ -252,11 +276,8 @@ fun SettingsScreen( BasicText(stringResource(R.string.settings_appearance), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } - // Liquid Glass Theme Mode Selector - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { + // Liquid-glass refracted segmented control + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { data class ThemeOption(val idx: Int, val label: String, val icon: ImageVector, val activeColor: Color) val options = listOf( ThemeOption(0, stringResource(R.string.settings_theme_auto), Icons.Rounded.PhoneAndroid, Color(0xFF0088FF)), @@ -265,30 +286,25 @@ fun SettingsScreen( ) options.forEach { option -> val isSelected = themeMode == option.idx - val itemContentColor = if (isSelected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) + val cc = if (isSelected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) LiquidButton( onClick = { onThemeModeChanged(option.idx) }, backdrop = backdrop, - tint = if (isSelected) option.activeColor else Color.Transparent, - surfaceColor = if (isSelected) option.activeColor.copy(0.18f) else (if (isLight) Color.White.copy(0.70f) else Color.White.copy(0.10f)), + tint = if (isSelected) option.activeColor else Color.Unspecified, + surfaceColor = if (isSelected) Color.Unspecified else (if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.10f)), modifier = Modifier.weight(1f) ) { Row( + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterHorizontally) ) { - Icon( - option.icon, null, - Modifier.size(17.dp), - itemContentColor - ) + Icon(option.icon, null, Modifier.size(16.dp), cc) BasicText( option.label, - style = TextStyle( - itemContentColor, - 13.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium - ) + style = TextStyle(cc, 13.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium), + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) } } @@ -305,93 +321,74 @@ fun SettingsScreen( ) } - // ── Save Location ── + // ── Language ── Column( Modifier .fillMaxWidth() .graphicsLayer { - alpha = panel2Alpha - translationY = panel2OffsetY * density + alpha = panel1Alpha + translationY = panel1OffsetY * density } .liquidGlassSection(isLight) .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon(Icons.Rounded.FolderOpen, null, Modifier.size(22.dp), Color(0xFF0088FF)) - BasicText(stringResource(R.string.settings_save_location), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) + Icon(Icons.Rounded.Language, null, Modifier.size(22.dp), label) + BasicText(stringResource(R.string.settings_language), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } - // Liquid Glass Save Location Mode Selector - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - val isDefault = saveUri == null - val activeBlue = Color(0xFF0088FF) - val activeGreen = Color(0xFF00C853) - - // 1. Default Downloads Button - val defContentColor = if (isDefault) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) - LiquidButton( - onClick = { - if (!isDefault) { - SaveLocationManager.clearSaveLocation(context) - saveUri = null - } - }, - backdrop = backdrop, - tint = if (isDefault) activeBlue else Color.Transparent, - surfaceColor = if (isDefault) activeBlue.copy(0.18f) else (if (isLight) Color.White.copy(0.70f) else Color.White.copy(0.10f)), - modifier = Modifier.weight(1f) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + data class LangOption(val code: String, val label: String) + val langs = listOf( + LangOption("en", stringResource(R.string.language_english)), + LangOption("pt-BR", stringResource(R.string.language_portuguese)) + ) + val accent = Color(0xFF0088FF) + langs.forEach { opt -> + val isSelected = selectedLocale == opt.code + val cc = if (isSelected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) + LiquidButton( + onClick = { onLocaleChanged(opt.code) }, + backdrop = backdrop, + tint = if (isSelected) accent else Color.Unspecified, + surfaceColor = if (isSelected) Color.Unspecified else (if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.10f)), + modifier = Modifier.weight(1f) ) { - Icon(Icons.Rounded.Download, null, Modifier.size(17.dp), defContentColor) BasicText( - stringResource(R.string.settings_save_downloads), - style = TextStyle( - defContentColor, - 13.sp, - fontWeight = if (isDefault) FontWeight.Bold else FontWeight.Medium - ) + opt.label, + style = TextStyle(cc, 13.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium), + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(vertical = 4.dp) ) } } + } + } - // 2. Custom Folder Button - val isCustom = !isDefault - val customContentColor = if (isCustom) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) - LiquidButton( - onClick = { folderPicker.launch(null) }, - backdrop = backdrop, - tint = if (isCustom) activeGreen else Color.Transparent, - surfaceColor = if (isCustom) activeGreen.copy(0.18f) else (if (isLight) Color.White.copy(0.70f) else Color.White.copy(0.10f)), - modifier = Modifier.weight(1f) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon(Icons.Rounded.FolderSpecial, null, Modifier.size(17.dp), customContentColor) - BasicText( - stringResource(R.string.settings_save_custom), - style = TextStyle( - customContentColor, - 13.sp, - fontWeight = if (isCustom) FontWeight.Bold else FontWeight.Medium - ) - ) - } + // ── Save Location ── + Column( + Modifier + .fillMaxWidth() + .graphicsLayer { + alpha = panel2Alpha + translationY = panel2OffsetY * density } + .liquidGlassSection(isLight) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Rounded.FolderOpen, null, Modifier.size(22.dp), Color(0xFF1976D2)) + BasicText(stringResource(R.string.settings_save_location), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } - // Path Details Card Row( Modifier .fillMaxWidth() @@ -402,25 +399,19 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - val iconColor = if (saveUri != null) Color(0xFF00C853) else Color(0xFF0088FF) Box( Modifier - .size(38.dp) + .size(40.dp) .clip(CircleShape) - .background(iconColor.copy(0.14f)), + .background(Color(0xFF1976D2).copy(0.14f)), contentAlignment = Alignment.Center ) { - Icon( - if (saveUri != null) Icons.Rounded.FolderSpecial else Icons.Rounded.Download, - null, - Modifier.size(19.dp), - iconColor - ) + Icon(Icons.Rounded.FolderOpen, null, Modifier.size(20.dp), Color(0xFF1976D2)) } Column(Modifier.weight(1f)) { BasicText( if (saveUri != null) stringResource(R.string.settings_custom_directory) else stringResource(R.string.settings_default_directory), - style = TextStyle(label, 13.sp, fontWeight = FontWeight.SemiBold) + style = TextStyle(label, 14.sp, fontWeight = FontWeight.SemiBold) ) val path = if (saveUri != null) { saveUri!!.lastPathSegment?.replace("primary:", "") ?: saveUri.toString() @@ -428,6 +419,32 @@ fun SettingsScreen( BasicText(path, style = TextStyle(sub, 12.sp)) } } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LiquidButton( + onClick = { folderPicker.launch(null) }, + backdrop = backdrop, + tint = Color(0xFF1976D2), + modifier = Modifier.weight(1f) + ) { + BasicText(stringResource(R.string.settings_change_folder), style = TextStyle(Color.White, 13.sp, fontWeight = FontWeight.SemiBold)) + } + if (saveUri != null) { + LiquidButton( + onClick = { + SaveLocationManager.clearSaveLocation(context) + saveUri = null + }, + backdrop = backdrop, + surfaceColor = Color.White.copy(0.08f) + ) { + BasicText(stringResource(R.string.settings_reset), style = TextStyle(text, 13.sp, fontWeight = FontWeight.SemiBold)) + } + } + } } // ── File Handling ── @@ -447,12 +464,12 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon(Icons.Rounded.AutoFixHigh, null, Modifier.size(22.dp), label) + Icon(Icons.Rounded.Description, null, Modifier.size(22.dp), label) BasicText(stringResource(R.string.settings_file_handling), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } SettingsToggleRow( - icon = Icons.Rounded.AutoFixHigh, + icon = Icons.Rounded.Compress, title = stringResource(R.string.settings_auto_compress), desc = stringResource(R.string.settings_auto_compress_desc), checked = autoCompress, @@ -479,21 +496,6 @@ fun SettingsScreen( subColor = sub ) - Box( - Modifier.fillMaxWidth().padding(vertical = 4.dp).height(1.dp) - .background(if (isLight) Color.Black.copy(0.04f) else Color.White.copy(0.06f)) - ) - - SettingsToggleRow( - icon = Icons.Rounded.Notifications, - title = stringResource(R.string.settings_notifications), - desc = stringResource(R.string.settings_notifications_desc), - checked = notifications, - onCheckedChange = { notifications = it; AppSettingsManager.setNotifications(context, it) }, - backdrop = backdrop, - labelColor = label, - subColor = sub - ) } // ── Default Quality ── @@ -553,7 +555,7 @@ fun SettingsScreen( } } - // ── Language Selection ── + // ── Personalization ── Column( Modifier .fillMaxWidth() @@ -563,43 +565,86 @@ fun SettingsScreen( } .liquidGlassSection(isLight) .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Row( + Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon(Icons.Rounded.Language, null, Modifier.size(22.dp), Color(0xFF0088FF)) - BasicText(stringResource(R.string.settings_language), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) + Icon(Icons.Rounded.Wallpaper, null, Modifier.size(22.dp), label) + BasicText(stringResource(R.string.settings_personalization), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - val languages = listOf( - Pair("en", stringResource(R.string.language_english)), - Pair("pt-BR", stringResource(R.string.language_portuguese)) - ) - languages.forEach { (code, labelText) -> - val isSelected = selectedLocale == code - val activeColor = Color(0xFF0088FF) - val contentColor = if (isSelected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) + SettingsToggleRow( + icon = Icons.Rounded.Wallpaper, + title = stringResource(R.string.settings_background), + desc = stringResource(R.string.settings_background_desc), + checked = showWallpaper, + onCheckedChange = onShowWallpaperChanged, + backdrop = backdrop, + labelColor = label, + subColor = sub + ) + + // When the background is on, let the user pick a custom image + reset to default. + if (showWallpaper) { + Row( + Modifier.fillMaxWidth().padding(top = 8.dp, start = 46.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { LiquidButton( - onClick = { onLocaleChanged(code) }, + onClick = { wallpaperPicker.launch(arrayOf("image/*")) }, backdrop = backdrop, - tint = if (isSelected) activeColor else Color.Transparent, - surfaceColor = if (isSelected) activeColor.copy(0.18f) else (if (isLight) Color.White.copy(0.70f) else Color.White.copy(0.10f)), + tint = Color(0xFF0088FF), modifier = Modifier.weight(1f) ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp) + ) { + Icon(Icons.Rounded.PhotoLibrary, null, Modifier.size(16.dp), Color.White) + BasicText(stringResource(R.string.settings_bg_gallery), style = TextStyle(Color.White, 13.sp, fontWeight = FontWeight.SemiBold), maxLines = 1) + } + } + LiquidIconButton( + onClick = { AppSettingsManager.clearCustomWallpaper(context); onCustomWallpaperChanged(null) }, + backdrop = backdrop, + surfaceColor = if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.10f), + modifier = Modifier.size(44.dp) + ) { + Icon(Icons.Rounded.Refresh, stringResource(R.string.settings_reset), Modifier.size(18.dp), if (hasCustomWallpaper) label else label.copy(0.4f)) + } + } + } + + // Replaying the tour also clears the completion flag (see the nav graph), so quitting + // the replay early does not leave it marked as seen-but-never-finished. + Spacer(Modifier.height(12.dp)) + LiquidButton( + onClick = onReplayOnboarding, + backdrop = backdrop, + surfaceColor = if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.10f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon(Icons.Rounded.AutoAwesome, null, Modifier.size(18.dp), label) + Column(Modifier.weight(1f)) { BasicText( - labelText, - style = TextStyle( - contentColor, - 13.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium - ), - modifier = Modifier.padding(vertical = 4.dp) + stringResource(R.string.settings_replay_onboarding), + style = TextStyle(text, 14.sp, fontWeight = FontWeight.SemiBold), + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + BasicText( + stringResource(R.string.settings_replay_onboarding_desc), + style = TextStyle(sub, 12.sp), + maxLines = 1, overflow = TextOverflow.Ellipsis ) } } @@ -691,16 +736,29 @@ fun SettingsScreen( .background(if (isLight) Color.Black.copy(0.04f) else Color.White.copy(0.06f)) ) + LicenseItem( + name = "Pdf_Tools", + author = "Karna14314", + license = "PDF viewer zoom/pan reference", + url = "https://github.com/Karna14314/Pdf_Tools", + labelColor = label, + subColor = sub + ) + + Box( + Modifier.fillMaxWidth().height(1.dp) + .background(if (isLight) Color.Black.copy(0.04f) else Color.White.copy(0.06f)) + ) + BasicText( stringResource(R.string.settings_license_notice), style = TextStyle(sub.copy(0.7f), 11.sp, lineHeight = 16.sp) ) } - // Dynamic bottom spacer: tab bar (64dp) + actual nav bar inset + breathing room - Spacer(Modifier.height( - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 84.dp - )) + // Clear the floating bottom navigation bar + system nav inset. + Spacer(Modifier.height(120.dp)) + } } } @@ -779,6 +837,16 @@ private fun LicenseItem( BasicText(stringResource(R.string.settings_license_author, author), style = TextStyle(subColor, 12.sp)) } BasicText(license, style = TextStyle(subColor, 11.sp)) - BasicText(url, style = TextStyle(Color(0xFF0088FF), 11.sp)) + val context = LocalContext.current + // Same blue URL text, now a tap target that opens the repo. No indication/ripple so the row + // looks exactly as before β€” only its behaviour changes. + BasicText( + url, + style = TextStyle(Color(0xFF0088FF), 11.sp), + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { openExternalLink(context, url) } + ) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SignaturePadDialog.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SignaturePadDialog.kt index 6b9c51a..2f80944 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SignaturePadDialog.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SignaturePadDialog.kt @@ -5,15 +5,23 @@ import android.graphics.Canvas import android.graphics.Paint import androidx.compose.ui.res.stringResource import com.chethan616.clearpdf.R +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn -import androidx.compose.animation.scaleIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,8 +43,8 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBackIosNew import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Undo import androidx.compose.material.icons.rounded.Gesture import androidx.compose.material3.Icon @@ -46,17 +54,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.focus.FocusRequester @@ -73,10 +84,12 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.chethan616.clearpdf.ui.components.LiquidButton import com.chethan616.clearpdf.ui.components.LiquidIconButton -import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.components.LiquidSlider +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor import com.kyant.backdrop.backdrops.LayerBackdrop import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * Full-screen signature capture dialog. Presents a dark canvas for the user to draw @@ -90,28 +103,42 @@ fun SignaturePadDialog( ) { val context = androidx.compose.ui.platform.LocalContext.current val uiSensor = rememberUISensor() + val scope = rememberCoroutineScope() + + // Theme-adaptive chrome. Everything that sits on the SCREEN background β€” header, action buttons, + // section labels β€” flips with the app theme: white ink on the dark screen, near-black on the + // light one. The signing paper (canvas) and the control tray stay light in both, so dark ink is + // always visible while drawing and stamps legibly onto white PDF pages. + val isDark = LocalIsDarkMode.current + val chrome = if (isDark) Color.White else Color(0xFF15171C) + val chromeSoft = chrome.copy(0.55f) + val chromeChip = if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f) + val screenGradient = if (isDark) + listOf(Color(0xFF171A21), Color(0xFF0C0E13), Color(0xFF060709)) + else + listOf(Color(0xFFF3F4F6), Color(0xFFEAECEF), Color(0xFFE2E5E9)) + // Light control tray: paper-on-dark needs no outline; white-on-light needs a hairline to define + // its edge against the near-white screen. + val trayColor = if (isDark) Color(0xFFF2F2EE) else Color(0xFFFFFFFF) + val cardBorder = if (isDark) Color.Transparent else Color.Black.copy(0.06f) data class StrokeItem(val points: List, val color: Color, val width: Float) val strokes = remember { mutableStateListOf() } var currentStroke by remember { mutableStateOf>(emptyList()) } var canvasSize by remember { mutableStateOf(IntSize.Zero) } + // Dark inks for a white "paper" pad β€” natural to sign with, and (unlike white ink) + // the resulting signature is actually visible when stamped onto a white PDF page. val signatureColors = listOf( - Color.White, - Color(0xFF2196F3), // Ink Blue - Color(0xFF4CAF50), // Emerald - Color(0xFFE91E63), // Crimson - Color(0xFF9C27B0), // Purple - Color(0xFFFF9800) // Gold + Color(0xFF141414), // Black + Color(0xFF1565C0), // Ink Blue + Color(0xFF0D3B66), // Navy + Color(0xFFB3261E), // Crimson + Color(0xFF1B5E20), // Green + Color(0xFF6A1B9A) // Purple ) var selectedColor by remember { mutableStateOf(signatureColors[0]) } - val strokeWidths = listOf( - 3.5f to stringResource(R.string.sig_thin), - 6.5f to stringResource(R.string.sig_medium), - 11f to stringResource(R.string.sig_thick), - 16f to stringResource(R.string.sig_heavy) - ) var selectedWidth by remember { androidx.compose.runtime.mutableFloatStateOf(6.5f) } var contentVisible by remember { mutableStateOf(false) } var showNamePrompt by remember { mutableStateOf(false) } @@ -123,6 +150,14 @@ fun SignaturePadDialog( contentVisible = true } + // Play the exit animation FULLY, THEN actually dismiss (Dialogs otherwise snap shut). + // Delay must be >= the exit duration below (fade 200 / slide 300) so it never cuts off. + val requestClose: () -> Unit = { + contentVisible = false + scope.launch { delay(310); onDismiss() } + Unit + } + LaunchedEffect(showNamePrompt) { if (showNamePrompt) { delay(120) @@ -130,7 +165,7 @@ fun SignaturePadDialog( } } - data class SavedSignature(val name: String, val bitmap: Bitmap) + data class SavedSignature(val name: String, val bitmap: Bitmap, val file: java.io.File) val savedSignatures = remember(context) { mutableStateListOf().apply { try { @@ -141,13 +176,16 @@ fun SignaturePadDialog( .loadSignature(file) ?.let { SavedSignature( com.chethan616.clearpdf.data.repository.SignatureManager.displayName(file), - it + it, + file ) } } .forEach(::add) } catch (_: Throwable) {} } } + // Long-pressed saved signature awaiting a delete confirmation. + var signatureToDelete by remember { mutableStateOf(null) } val confirmSignatureName = { val bitmap = pendingSignature @@ -164,18 +202,34 @@ fun SignaturePadDialog( } Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnBackPress = true) + onDismissRequest = requestClose, + // decorFitsSystemWindows = false β†’ the dialog draws edge-to-edge (behind the + // status/nav bars) so there are no gaps above/below. dismissOnBackPress = false + // so the back gesture routes through requestClose and plays the exit animation. + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + decorFitsSystemWindows = false + ) ) { + BackHandler { requestClose() } AnimatedVisibility( visible = contentVisible, modifier = Modifier.fillMaxSize(), - enter = fadeIn(tween(220)) + scaleIn(initialScale = 0.98f) + // Slide in/out like a pushed screen (not a modal pop). + enter = fadeIn(tween(200)) + slideInHorizontally(tween(300)) { it / 3 }, + exit = fadeOut(tween(200)) + slideOutHorizontally(tween(260)) { it / 3 } ) { + Box( + Modifier + .fillMaxSize() + // A soft top-lit gradient (dark or light per theme) gives the screen depth and + // lets the light signing cards read as "paper on a desk" rather than floating. + .background(Brush.verticalGradient(screenGradient)) + ) { Column( Modifier .fillMaxSize() - .background(Color(0xFF0A0A0A)) .statusBarsPadding() .navigationBarsPadding() .padding(16.dp), @@ -188,108 +242,118 @@ fun SignaturePadDialog( verticalAlignment = Alignment.CenterVertically ) { LiquidIconButton( - onClick = onDismiss, + onClick = requestClose, backdrop = backdrop, - surfaceColor = Color.White.copy(0.08f), + surfaceColor = chromeChip, modifier = Modifier.size(44.dp) ) { - Icon(Icons.Rounded.Close, stringResource(R.string.close), Modifier.size(20.dp), Color.White) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), chrome) } - BasicText( - stringResource(R.string.sig_draw_title), - style = TextStyle(Color.White.copy(0.85f), 17.sp, FontWeight.SemiBold) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Rounded.Gesture, null, Modifier.size(19.dp), chromeSoft) + BasicText( + stringResource(R.string.sig_draw_title), + style = TextStyle(chrome, 18.sp, FontWeight.Bold, letterSpacing = 0.2.sp) + ) + } LiquidIconButton( onClick = { if (strokes.isNotEmpty()) strokes.removeAt(strokes.lastIndex) }, backdrop = backdrop, - surfaceColor = Color.White.copy(0.08f), + surfaceColor = chromeChip, modifier = Modifier.size(44.dp) ) { - Icon(Icons.Rounded.Undo, stringResource(R.string.undo), Modifier.size(20.dp), Color.White) + // Subtle when there's nothing to undo. + Icon( + Icons.Rounded.Undo, stringResource(R.string.undo), Modifier.size(20.dp), + chrome.copy(if (strokes.isEmpty()) 0.32f else 1f) + ) } } Spacer(Modifier.height(8.dp)) - // Color & Line Width Controls + // Light control tray: ink colours (no outline rings β€” they read cleanly on + // the light surface) and a liquid-glass thickness slider. Column( - Modifier.fillMaxWidth(), + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(trayColor) + .border(1.dp, cardBorder, RoundedCornerShape(22.dp)) + .padding(horizontal = 18.dp, vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Color palette + // Ink colour beads β€” plain, no outline circle. Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .background(Color.White.copy(0.08f)) - .padding(horizontal = 14.dp, vertical = 6.dp) + modifier = Modifier.fillMaxWidth() ) { signatureColors.forEach { color -> val isSelected = selectedColor == color - Box( - Modifier - .size(28.dp) - .clip(CircleShape) - .background(color) - .border( - if (isSelected) 2.5.dp else 1.dp, - if (isSelected) Color.White else Color.White.copy(0.3f), - CircleShape - ) - .clickable { - selectedColor = color - if (strokes.isNotEmpty()) { - strokes.indices.forEach { idx -> - strokes[idx] = strokes[idx].copy(color = color) - } + // Springy grow on selection instead of a hard size jump β€” the bead reads as + // a physical thing you press, matching the rest of the liquid chrome. + val beadSize by animateDpAsState( + if (isSelected) 40.dp else 30.dp, + spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMediumLow), + label = "beadSize" + ) + LiquidIconButton( + onClick = { + selectedColor = color + if (strokes.isNotEmpty()) { + strokes.indices.forEach { idx -> + strokes[idx] = strokes[idx].copy(color = color) } - }, - contentAlignment = Alignment.Center + } + }, + backdrop = backdrop, + surfaceColor = color, + modifier = Modifier.size(beadSize) ) { - if (isSelected) { - Box( - Modifier - .size(7.dp) - .clip(CircleShape) - .background(if (color == Color.White) Color.Black else Color.White) - ) - } + if (isSelected) Icon(Icons.Rounded.Check, null, Modifier.size(17.dp), Color.White) } } } - // Stroke width pills + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.Black.copy(0.07f)) + ) + + // Thickness: liquid-glass slider with a live ink-dot preview. Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .background(Color.White.copy(0.08f)) - .padding(horizontal = 12.dp, vertical = 6.dp) + horizontalArrangement = Arrangement.spacedBy(14.dp) ) { - strokeWidths.forEach { (w, label) -> - val isSelected = selectedWidth == w + Box( + Modifier.size(22.dp), + contentAlignment = Alignment.Center + ) { Box( Modifier - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) Color.White.copy(0.25f) else Color.Transparent) - .clickable { selectedWidth = w } - .padding(horizontal = 12.dp, vertical = 6.dp) - ) { - BasicText( - label, - style = TextStyle( - if (isSelected) Color.White else Color.White.copy(0.6f), - 12.sp, - if (isSelected) FontWeight.Bold else FontWeight.Medium - ) - ) - } + .size((selectedWidth * 1.1f).dp.coerceIn(4.dp, 20.dp)) + .clip(CircleShape) + .background(selectedColor) + ) } + LiquidSlider( + value = { selectedWidth }, + onValueChange = { selectedWidth = it.coerceIn(2f, 20f) }, + valueRange = 2f..20f, + visibilityThreshold = 0.1f, + backdrop = backdrop, + modifier = Modifier.weight(1f) + ) } } @@ -301,8 +365,8 @@ fun SignaturePadDialog( .weight(1f) .fillMaxWidth() .clip(RoundedCornerShape(20.dp)) - .background(Color(0xFF111111)) - .border(1.dp, Color.White.copy(0.12f), RoundedCornerShape(20.dp)) + .background(Color(0xFFF7F7F3)) + .border(1.dp, Color.Black.copy(0.10f), RoundedCornerShape(20.dp)) .onSizeChanged { canvasSize = it } .pointerInput(selectedColor, selectedWidth) { detectDragGestures( @@ -323,23 +387,31 @@ fun SignaturePadDialog( ) } ) { - // Baseline hint + // Empty-canvas guides: a subtle centered "Sign here", plus a faint + // signature baseline with an Γ— marker near the lower third. if (strokes.isEmpty() && currentStroke.isEmpty()) { - Box( - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 48.dp) - .fillMaxWidth(0.75f) - .height(1.dp) - .background(Color.White.copy(0.15f)) - ) BasicText( stringResource(R.string.sig_hint), - style = TextStyle(Color.White.copy(0.2f), 14.sp), - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 24.dp) + style = TextStyle(Color.Black.copy(0.20f), 14.sp, FontWeight.Medium), + modifier = Modifier.align(Alignment.Center) ) + Row( + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 44.dp) + .fillMaxWidth(0.84f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + BasicText("βœ•", style = TextStyle(Color.Black.copy(0.22f), 16.sp, FontWeight.Bold)) + Box( + Modifier + .weight(1f) + .height(1.dp) + .clip(RoundedCornerShape(50)) + .background(Color.Black.copy(0.14f)) + ) + } } Canvas(Modifier.fillMaxSize()) { @@ -387,38 +459,61 @@ fun SignaturePadDialog( Spacer(Modifier.height(10.dp)) BasicText( stringResource(R.string.sig_saved_title), - style = TextStyle(Color.White.copy(0.7f), 12.sp, FontWeight.SemiBold) + style = TextStyle(chrome.copy(0.6f), 12.sp, FontWeight.SemiBold, letterSpacing = 0.3.sp) ) Row( Modifier .fillMaxWidth() + .horizontalScroll(rememberScrollState()) .padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - savedSignatures.take(4).forEach { savedBmp -> - Box( - Modifier - .size(70.dp, 44.dp) - .clip(RoundedCornerShape(10.dp)) - .background(Color.White.copy(0.10f)) - .border(1.dp, Color.White.copy(0.2f), RoundedCornerShape(10.dp)) - .clickable { - val safeBmp = if (savedBmp.bitmap.config == Bitmap.Config.HARDWARE || !savedBmp.bitmap.isMutable) { - savedBmp.bitmap.copy(Bitmap.Config.ARGB_8888, true) - } else { - savedBmp.bitmap - } - onSignatureCaptured(safeBmp) - }, - contentAlignment = Alignment.Center + savedSignatures.forEach { savedBmp -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .pointerInput(savedBmp.file) { + detectTapGestures( + onTap = { + val safeBmp = if (savedBmp.bitmap.config == Bitmap.Config.HARDWARE || !savedBmp.bitmap.isMutable) { + savedBmp.bitmap.copy(Bitmap.Config.ARGB_8888, true) + } else { + savedBmp.bitmap + } + onSignatureCaptured(safeBmp) + }, + // Long-press β†’ ask to delete this saved signature. + onLongPress = { signatureToDelete = savedBmp } + ) + } + .padding(2.dp) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + // Preview the actual signature ink on a light "paper" tile so + // the dark ink reads (matches the signing surface). + Box( + Modifier + .size(84.dp, 50.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFFF7F7F3)) + // A dark hairline reads on the light paper tile; the old white + // border was invisible against it. + .border(1.dp, Color.Black.copy(0.08f), RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center ) { - Icon(Icons.Rounded.Gesture, null, Modifier.size(12.dp), Color.White) - BasicText(savedBmp.name, style = TextStyle(Color.White, 11.sp, FontWeight.Medium)) + androidx.compose.foundation.Image( + bitmap = savedBmp.bitmap.asImageBitmap(), + contentDescription = savedBmp.name, + modifier = Modifier.fillMaxSize().padding(6.dp), + contentScale = androidx.compose.ui.layout.ContentScale.Fit + ) } + BasicText( + savedBmp.name, + style = TextStyle(chrome.copy(0.7f), 10.sp, FontWeight.Medium), + maxLines = 1 + ) } } } @@ -426,34 +521,25 @@ fun SignaturePadDialog( Spacer(Modifier.height(14.dp)) - // Bottom actions - if (showNamePrompt) { - SignatureNameBar( - backdrop = backdrop, - uiSensor = uiSensor, - name = signatureName, - focusRequester = nameFocusRequester, - onNameChange = { signatureName = it }, - onCancel = { - showNamePrompt = false - pendingSignature = null - }, - onSave = confirmSignatureName - ) - } else Row( + // Bottom draw actions. Name entry is a floating overlay (below) so the + // keyboard never displaces this layout. + Row( Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { + val hasInk = strokes.isNotEmpty() + // Secondary, subdued when there's nothing to clear. LiquidButton( - onClick = { strokes.clear(); currentStroke = emptyList() }, + onClick = { if (hasInk) { strokes.clear(); currentStroke = emptyList() } }, backdrop = backdrop, - surfaceColor = Color.White.copy(0.08f), + surfaceColor = chrome.copy(if (hasInk) 0.10f else 0.04f), modifier = Modifier.weight(1f) ) { BasicText( stringResource(R.string.sig_clear), - style = TextStyle(Color.White.copy(0.75f), 15.sp, FontWeight.Medium), - modifier = Modifier.padding(vertical = 6.dp) + style = TextStyle(chrome.copy(if (hasInk) 0.85f else 0.32f), 15.sp, FontWeight.Medium), + modifier = Modifier.padding(vertical = 9.dp) ) } @@ -493,13 +579,13 @@ fun SignaturePadDialog( } }, backdrop = backdrop, - tint = Color(0xFF00C853), - modifier = Modifier.weight(2f) + tint = if (hasInk) Color(0xFF00C853) else Color(0xFF2E7D32).copy(0.55f), + modifier = Modifier.weight(1.7f) ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 6.dp) + modifier = Modifier.padding(vertical = 9.dp) ) { Icon(Icons.Rounded.Check, null, Modifier.size(18.dp), Color.White) BasicText( @@ -509,85 +595,179 @@ fun SignaturePadDialog( } } } + } + + } + } + } + + // Naming uses its own modal window so the platform pans it cleanly above the + // keyboard and the signature canvas behind it never reflows. + if (showNamePrompt) { + SignatureNameDialog( + backdrop = backdrop, + uiSensor = uiSensor, + name = signatureName, + focusRequester = nameFocusRequester, + onNameChange = { signatureName = it }, + onDismiss = { showNamePrompt = false; pendingSignature = null }, + onSave = confirmSignatureName + ) + } + + // Delete a saved signature (from a long-press on its thumbnail). + signatureToDelete?.let { sig -> + Dialog(onDismissRequest = { signatureToDelete = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Column( + Modifier + .fillMaxWidth(0.82f) + .clip(RoundedCornerShape(24.dp)) + .background(if (isDark) Color(0xFF1B1E25) else Color.White) + .border(1.dp, if (isDark) Color.White.copy(0.12f) else Color.Black.copy(0.08f), RoundedCornerShape(24.dp)) + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + BasicText( + stringResource(R.string.sig_delete_title), + style = TextStyle(chrome, 17.sp, FontWeight.Bold) + ) + BasicText( + stringResource(R.string.sig_delete_msg, sig.name), + style = TextStyle(chrome.copy(0.72f), 14.sp) + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End) + ) { + LiquidButton( + onClick = { signatureToDelete = null }, + backdrop = backdrop, + surfaceColor = chromeChip + ) { + BasicText(stringResource(R.string.cancel), style = TextStyle(chrome, 13.sp), modifier = Modifier.padding(vertical = 4.dp)) + } + LiquidButton( + onClick = { + runCatching { + com.chethan616.clearpdf.data.repository.SignatureManager.deleteSignature(sig.file) + } + savedSignatures.remove(sig) + signatureToDelete = null + }, + backdrop = backdrop, + tint = Color(0xFFEF5350) + ) { + BasicText(stringResource(R.string.delete), style = TextStyle(Color.White, 13.sp, FontWeight.SemiBold), modifier = Modifier.padding(vertical = 4.dp)) + } + } + } } } -} } @Composable -private fun SignatureNameBar( +private fun SignatureNameDialog( backdrop: LayerBackdrop, uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, name: String, focusRequester: FocusRequester, onNameChange: (String) -> Unit, - onCancel: () -> Unit, + onDismiss: () -> Unit, onSave: () -> Unit ) { val canSave = name.trim().isNotEmpty() + // Same theme-adaptive chrome as the signing screen so the naming sheet matches it in both modes. + val isDark = LocalIsDarkMode.current + val chrome = if (isDark) Color.White else Color(0xFF15171C) + val cardBg = if (isDark) Color(0xFF1B1E25) else Color.White + val cardBorder = if (isDark) Color.White.copy(0.12f) else Color.Black.copy(0.08f) + val fieldBg = if (isDark) Color.White.copy(0.12f) else Color.Black.copy(0.05f) + val chipBg = if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f) - Row( - Modifier - .fillMaxWidth() - .imePadding() - .liquidGlassPanel(backdrop, uiSensor) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( + LaunchedEffect(Unit) { + delay(150) + runCatching { focusRequester.requestFocus() } + } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Column( Modifier - .weight(1f) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(0.12f)) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + .fillMaxWidth(0.9f) + .clip(RoundedCornerShape(26.dp)) + // Solid card (matches the signature screen) instead of sampling + // the wallpaper PNG through glass in a separate Dialog window. + .background(cardBg) + .border(1.dp, cardBorder, RoundedCornerShape(26.dp)) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Icon(Icons.Rounded.Gesture, null, Modifier.size(16.dp), Color.White.copy(0.6f)) - Box(Modifier.weight(1f)) { - if (name.isEmpty()) { - BasicText( - stringResource(R.string.sig_name_hint), - style = TextStyle(Color.White.copy(0.45f), 13.sp) + BasicText( + stringResource(R.string.sig_name_title), + style = TextStyle(chrome, 16.sp, FontWeight.Bold) + ) + + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(fieldBg) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Rounded.Gesture, null, Modifier.size(18.dp), chrome.copy(0.6f)) + Box(Modifier.weight(1f)) { + if (name.isEmpty()) { + BasicText( + stringResource(R.string.sig_name_hint), + style = TextStyle(chrome.copy(0.45f), 14.sp) + ) + } + BasicTextField( + value = name, + onValueChange = onNameChange, + textStyle = TextStyle(chrome, 14.sp), + cursorBrush = SolidColor(chrome), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (canSave) onSave() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) ) } - BasicTextField( - value = name, - onValueChange = onNameChange, - textStyle = TextStyle(Color.White, 13.sp), - cursorBrush = SolidColor(Color.White), - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { onSave() }), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - ) } - } - LiquidButton( - onClick = onCancel, - backdrop = backdrop, - surfaceColor = Color.White.copy(0.08f), - modifier = Modifier.width(86.dp) - ) { - BasicText( - stringResource(R.string.cancel), - style = TextStyle(Color.White.copy(0.78f), 12.sp, FontWeight.Medium) - ) - } - LiquidButton( - onClick = onSave, - backdrop = backdrop, - tint = if (canSave) Color(0xFF00C853) else Color.White.copy(0.08f), - modifier = Modifier.width(96.dp) - ) { - BasicText( - stringResource(R.string.sig_name_save), - style = TextStyle(Color.White.copy(if (canSave) 1f else 0.45f), 12.sp, FontWeight.SemiBold) - ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + LiquidButton( + onClick = onDismiss, + backdrop = backdrop, + surfaceColor = chipBg, + modifier = Modifier.width(100.dp) + ) { + BasicText( + stringResource(R.string.cancel), + style = TextStyle(chrome.copy(0.78f), 13.sp, FontWeight.Medium), + modifier = Modifier.padding(vertical = 4.dp) + ) + } + LiquidButton( + onClick = { if (canSave) onSave() }, + backdrop = backdrop, + tint = if (canSave) Color(0xFF00C853) else chipBg, + modifier = Modifier.width(110.dp) + ) { + BasicText( + stringResource(R.string.sig_name_save), + style = TextStyle((if (canSave) Color.White else chrome).copy(if (canSave) 1f else 0.45f), 13.sp, FontWeight.SemiBold), + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SplitPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SplitPdfScreen.kt index 77652bb..68c965d 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SplitPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SplitPdfScreen.kt @@ -52,7 +52,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.ui.components.LiquidButton -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.GlassScreenHeaderRow +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor @@ -108,11 +110,6 @@ fun SplitPdfScreen( animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), label = "splitTopBarAlpha" ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 16f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 500, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "splitTopBarOffsetY" - ) val contentAlpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (isVisible) 1f else 0f, @@ -125,31 +122,23 @@ fun SplitPdfScreen( label = "splitContentOffsetY" ) - Column( - Modifier - .fillMaxSize() - .statusBarsPadding() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { - Row( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) - } - LiquidGlassTopBar(title = stringResource(R.string.tool_split), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) + GlassScreenScaffold( + backdrop = backdrop, + header = { headerBackdrop -> + // Fade only β€” the header is glass, and translating glass re-runs its blur+lens. + GlassScreenHeaderRow( + title = stringResource(R.string.tool_split), + backdrop = headerBackdrop, + onBack = onBack, + modifier = Modifier.graphicsLayer { alpha = topBarAlpha } + ) } - + ) { contentPadding -> Column( Modifier - .fillMaxWidth() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(contentPadding) .graphicsLayer { alpha = contentAlpha translationY = contentOffsetY * density @@ -320,12 +309,12 @@ fun SplitPdfScreen( } LiquidButton( - onClick = { viewModel.onRunPrimaryAction(context) }, + onClick = { if (!state.isSplitting) viewModel.onRunPrimaryAction(context) }, backdrop = backdrop, tint = accent, - isInteractive = !state.isSplitting + modifier = Modifier.fillMaxWidth() ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { if (state.isSplitting) { CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) } else { @@ -336,7 +325,7 @@ fun SplitPdfScreen( Color.White ) } - BasicText(actionLabel, style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + BasicText(actionLabel, style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1) } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt new file mode 100644 index 0000000..6c46f62 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt @@ -0,0 +1,656 @@ +package com.chethan616.clearpdf.ui.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBackIosNew +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ChevronLeft +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.GridOn +import androidx.compose.material.icons.rounded.IosShare +import androidx.compose.material.icons.rounded.PictureAsPdf +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.launch +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold +import com.chethan616.clearpdf.ui.components.GlassTitlePill +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidIconButton +import com.chethan616.clearpdf.ui.components.ShareMorphButton +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.components.viewerChromeGlass +import com.chethan616.clearpdf.ui.components.viewerGlass +import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.SpreadsheetViewModel +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.shapes.RoundedRectangle + +private val CELL_W = 132.dp +private val CELL_H = 38.dp + +/** + * `liquidGlassPanel`'s own corner curve, restated so the scrolling grid can be clipped to it. + * + * The panel is read-only and its radius lives in a default argument, so this is a copy rather than + * a reference. Keep the two in step β€” a clip that disagrees with the paint reads as a chipped edge. + */ +private val GlassPanelShape: Shape = RoundedRectangle(28f.dp) + +/** The cell a tap selected, carried into the value/edit popup. */ +private data class CellRef(val row: Int, val col: Int, val value: String) + +/** Icon + label, so Copy / Edit / Save all sit the same inside a [LiquidButton]. */ +@Composable +private fun CellActionLabel(icon: ImageVector, label: String, tint: Color) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(icon, null, Modifier.size(16.dp), tint) + BasicText(label, style = TextStyle(tint, 14.sp, FontWeight.Medium)) + } +} + +/** Interactive spreadsheet viewer: a real scrollable grid with sticky column letters, sheet + * navigation, and tap-a-cell-to-see-its-full-value (so long values are never lost to "…"). */ +@Composable +fun SpreadsheetViewerScreen( + backdrop: LayerBackdrop, + viewModel: SpreadsheetViewModel, + onBack: () -> Unit, + onOpenPdf: (android.net.Uri) -> Unit = {} +) { + val state by viewModel.state.collectAsState() + val isDark = LocalIsDarkMode.current + val text = LiquidGlassColors.text(isDark) + val sub = LiquidGlassColors.secondary(isDark) + val accent = Color(0xFF1E8E5A) // spreadsheet green + val uiSensor = rememberUISensor() + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + var sheetIndex by remember { mutableIntStateOf(0) } + // The tapped cell, plus whether its popup is in read or edit mode. `draft` holds the in-progress + // text so cancelling leaves the sheet untouched. + var selectedCell by remember { mutableStateOf(null) } + var editingCell by remember { mutableStateOf(false) } + var draft by remember { mutableStateOf("") } + val editFocus = remember { FocusRequester() } + var zoom by remember { mutableFloatStateOf(1f) } + var showSearch by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var currentMatch by remember { mutableIntStateOf(0) } + var showSheetPicker by remember { mutableStateOf(false) } + val searchFocus = remember { FocusRequester() } + val gridListState = rememberLazyListState() + // Theme-adaptive glass surface for the top-bar pill / buttons β†’ true liquid-glass refraction. + val chromeGlass = viewerChromeGlass(isDark) + + val sheets = state.sheets + val idx = sheetIndex.coerceIn(0, sheets.lastIndex.coerceAtLeast(0)) + val currentSheet = sheets.getOrNull(idx) + + // Cell search across the current sheet β†’ list of (row, col) matches. + val matches = remember(currentSheet, searchQuery) { + val q = searchQuery.trim() + if (q.isBlank() || currentSheet == null) emptyList() + else buildList { + currentSheet.rows.forEachIndexed { r, row -> + row.forEachIndexed { c, v -> if (v.contains(q, ignoreCase = true)) add(r to c) } + } + } + } + val matchSet = remember(matches) { matches.mapTo(HashSet()) { it.first.toLong() * 1_000_000L + it.second } } + val currentCell = matches.getOrNull(currentMatch) + LaunchedEffect(matches) { + currentMatch = 0 + if (matches.isNotEmpty()) gridListState.animateScrollToItem(matches[0].first) + } + fun goToMatch(delta: Int) { + if (matches.isEmpty()) return + currentMatch = ((currentMatch + delta) % matches.size + matches.size) % matches.size + scope.launch { gridListState.animateScrollToItem(matches[currentMatch].first) } + } + + Box(Modifier.fillMaxSize()) { + GlassScreenScaffold( + backdrop = backdrop, + contentHorizontalPadding = 12.dp, + headerHorizontalPadding = 12.dp, + // Header β€” Home and Tools' trio, verbatim: back circle Β· centred [GlassTitlePill] Β· + // search circle, 10 dp apart. The pill is the same widget carrying "ClearPDF" on Home, + // so the two can't drift apart; it shows "Sheet X / Y" and (for multi-sheet files) opens + // a sheet picker on tap. It is pinned over the grid and samples the content layer, so + // rows scroll *under* the chrome and refract through it rather than pushing it down. + header = { headerBackdrop -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + // No `surfaceColor` β€” Home's circles paint nothing of their own and are pure + // refraction. The heavier `chromeGlass` tint that used to be here is still right + // for the grid container below, but on floating chrome it read as a grey slab. + LiquidIconButton(onClick = onBack, backdrop = headerBackdrop) { + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(16.dp), text) + } + // Weighted Box, not two weighted spacers β€” the two circles are both 40 dp, so + // this is what actually centres the pill on the row, the way Home does it. + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + GlassTitlePill( + text = if (currentSheet != null) stringResource(R.string.viewer_sheet_of, idx + 1, sheets.size) + else state.fileName.ifBlank { stringResource(R.string.viewer_title) }, + backdrop = headerBackdrop, + // No overrides at all, exactly as Home calls it. Both defaults already + // resolve to what this screen was passing by hand β€” the pill's own tint + // is the theme's, and its ink is `LiquidGlassColors.text(isDark)`, which + // is what `text` is here. + onClick = if (sheets.size > 1) ({ showSheetPicker = true }) else null + ) + } + LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop) { + Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), text) + } + } + } + ) { contentPadding -> + when { + state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = accent, strokeWidth = 2.5.dp) + } + currentSheet == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + BasicText(state.error ?: "Empty spreadsheet", style = TextStyle(sub, 14.sp)) + } + else -> { + // The grid used to sit straight on the wallpaper: cells are mostly transparent, + // so whatever photo was behind the app showed through the data. It now rides on + // the same heavy glass the sheet picker and the page-jump dialog use β€” blur 8, + // a 20x40 depth lens, highlight and inner shadow β€” which is the one place in + // this app that stack is right outside a dialog, because this *is* a reading + // surface that has to hold small text over an arbitrary backdrop. + // + // The tint is pushed well past `liquidGlassPanel`'s 40% default. At 40% a busy + // wallpaper still reads through 11 sp cell text. 72% keeps the refraction and + // the depth lens plainly visible while giving the type something to sit on. + val sheetSurface = + if (isDark) Color(0xFF15181E).copy(0.72f) else Color(0xFFF7F8FA).copy(0.72f) + Box( + Modifier + .fillMaxSize() + .padding(contentPadding) + .liquidGlassPanel(backdrop, uiSensor, containerColorOverride = sheetSurface) + // `liquidGlassPanel` paints its shape but does not clip, and the grid + // scrolls β€” without this the rows run out past the rounded corners. + .clip(GlassPanelShape) + ) { + SheetGrid( + sheet = currentSheet, isDark = isDark, text = text, sub = sub, accent = accent, zoom = zoom, + listState = gridListState, matchSet = matchSet, currentCell = currentCell, + onZoom = { z -> zoom = (zoom * z).coerceIn(0.7f, 2f) }, + onCellTap = { r, c, value -> + selectedCell = CellRef(r, c, value) + editingCell = false + draft = value + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + + // Tap-a-cell β†’ full value popup (fixes truncated "…" cells), and the entry point for editing. + AnimatedVisibility( + visible = selectedCell != null, + enter = fadeIn(tween(150)) + scaleIn(initialScale = 0.94f, animationSpec = tween(160)), + exit = fadeOut(tween(140)) + scaleOut(targetScale = 0.96f) + ) { + Box( + Modifier.fillMaxSize().background(Color.Black.copy(0.28f)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { selectedCell = null } + // The edit field lives in here, so the card has to ride above the keyboard. + .imePadding(), + contentAlignment = Alignment.Center + ) { + selectedCell?.let { cell -> + Column( + Modifier + .fillMaxWidth().padding(28.dp) + .viewerGlass(backdrop, chromeGlass) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + // "B7 Β· Cell value" β€” the ref matters once you can change what's in it. + BasicText( + "${colLetter(cell.col)}${cell.row + 1} Β· ${stringResource(R.string.sheet_cell_value)}", + style = TextStyle(sub, 11.sp, FontWeight.Bold, letterSpacing = 0.8.sp) + ) + + if (editingCell) { + LaunchedEffect(Unit) { runCatching { editFocus.requestFocus() } } + Box( + Modifier.fillMaxWidth().heightIn(min = 56.dp, max = 320.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f)) + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + BasicTextField( + value = draft, + onValueChange = { draft = it }, + textStyle = TextStyle(text, 16.sp), + cursorBrush = SolidColor(accent), + modifier = Modifier.fillMaxWidth().focusRequester(editFocus) + ) + } + } else { + Column( + Modifier.fillMaxWidth().heightIn(max = 320.dp).verticalScroll(rememberScrollState()) + ) { + BasicText( + cell.value.ifBlank { stringResource(R.string.sheet_cell_empty) }, + style = TextStyle(if (cell.value.isBlank()) sub else text, 16.sp) + ) + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { + if (editingCell) { + LiquidButton( + onClick = { editingCell = false; draft = cell.value }, + backdrop = backdrop, + surfaceColor = chromeGlass + ) { + BasicText(stringResource(R.string.cancel), style = TextStyle(text, 14.sp, FontWeight.Medium)) + } + LiquidButton( + onClick = { + viewModel.updateCell(idx, cell.row, cell.col, draft) + selectedCell = null + editingCell = false + }, + backdrop = backdrop, + surfaceColor = accent.copy(0.85f) + ) { + CellActionLabel(Icons.Rounded.Check, stringResource(R.string.save), Color.White) + } + } else { + LiquidButton( + onClick = { clipboard.setText(AnnotatedString(cell.value)); selectedCell = null }, + backdrop = backdrop, + surfaceColor = chromeGlass + ) { + CellActionLabel(Icons.Rounded.ContentCopy, stringResource(R.string.copy), text) + } + LiquidButton( + onClick = { draft = cell.value; editingCell = true }, + backdrop = backdrop, + surfaceColor = accent.copy(0.28f) + ) { + CellActionLabel(Icons.Rounded.Edit, stringResource(R.string.edit), accent) + } + } + } + } + } + } + } + + // ── Share-morph (identical to the PDF viewer): tap = open/export as PDF, long-press + + // swipe-up = share. Bottom-right, growing strictly upward. + if (currentSheet != null && !showSearch) { + ShareMorphButton( + backdrop = backdrop, + glass = chromeGlass, + fg = text, + onOpen = { viewModel.exportToPdf(context) { u -> u?.let(onOpenPdf) } }, + onShare = { state.fileUri?.let { shareFile(context, it) } }, + idleIcon = Icons.Rounded.PictureAsPdf, + idleContentDesc = stringResource(R.string.sheet_export_pdf), + modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 16.dp) + ) + } + + // ── Search bar (reused from the PDF viewer): searches cells, highlights matches, and + // Prev/Next scrolls to them. iOS-style, slides up from the bottom. + AnimatedVisibility( + visible = showSearch, + enter = fadeIn(tween(180)) + slideInVertically { it }, + exit = fadeOut(tween(140)) + slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding().imePadding().padding(horizontal = 12.dp, vertical = 12.dp) + ) { + PdfSearchBar( + query = searchQuery, + matchCount = matches.size, + currentMatchIndex = currentMatch, + focusRequester = searchFocus, + backdrop = backdrop, + uiSensor = uiSensor, + fg = text, + fgSoft = sub, + // Translucent, not the old opaque slab colour β€” the search pill is glass now, and + // an opaque surface would kill its refraction. + surface = chromeGlass, + onQueryChange = { searchQuery = it }, + onPrevMatch = { goToMatch(-1) }, + onNextMatch = { goToMatch(1) }, + onClose = { showSearch = false; searchQuery = "" } + ) + } + + // Sheet picker β€” tap the "Sheet X / Y" pill to jump to any sheet (mirrors PDF page-jump). + SheetPickerPopup( + visible = showSheetPicker, + sheets = sheets, + currentIndex = idx, + backdrop = backdrop, + uiSensor = uiSensor, + isDark = isDark, + onPick = { i -> sheetIndex = i; showSheetPicker = false }, + onDismiss = { showSheetPicker = false } + ) + } +} + +/** In-window liquid-glass sheet picker (scrim + scale-in panel), modelled on LiquidPageJumpPopup. */ +@Composable +private fun SheetPickerPopup( + visible: Boolean, + sheets: List, + currentIndex: Int, + backdrop: LayerBackdrop, + uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, + isDark: Boolean, + onPick: (Int) -> Unit, + onDismiss: () -> Unit +) { + val text = LiquidGlassColors.text(isDark) + val sub = LiquidGlassColors.secondary(isDark) + val accent = Color(0xFF1E8E5A) + Box(Modifier.fillMaxSize()) { + AnimatedVisibility(visible, enter = fadeIn(tween(200)), exit = fadeOut(tween(180)), modifier = Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(0.45f)).pointerInput(Unit) { detectTapGestures { onDismiss() } }) + } + AnimatedVisibility( + visible, + enter = fadeIn(tween(220)) + scaleIn(initialScale = 0.85f, animationSpec = spring(dampingRatio = 0.72f, stiffness = Spring.StiffnessMediumLow)), + exit = fadeOut(tween(140)) + scaleOut(targetScale = 0.9f, animationSpec = tween(150)), + modifier = Modifier.align(Alignment.Center).padding(28.dp) + ) { + Column( + Modifier.fillMaxWidth().widthIn(max = 360.dp) + .viewerGlass(backdrop, viewerChromeGlass(isDark)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + BasicText( + stringResource(R.string.sheet_picker_title), + style = TextStyle(sub, 11.sp, FontWeight.Bold, letterSpacing = 0.8.sp), + modifier = Modifier.padding(start = 4.dp, bottom = 6.dp) + ) + Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(2.dp)) { + sheets.forEachIndexed { i, s -> + val selected = i == currentIndex + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)) + .background(if (selected) accent.copy(0.16f) else Color.Transparent) + .clickable { onPick(i) } + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon(Icons.Rounded.GridOn, null, Modifier.size(18.dp), if (selected) accent else sub) + BasicText(s.name, style = TextStyle(if (selected) accent else text, 15.sp, if (selected) FontWeight.SemiBold else FontWeight.Normal), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (selected) Icon(Icons.Rounded.Check, null, Modifier.size(18.dp), accent) + } + } + } + } + } + } +} + +@Composable +private fun SheetGrid( + sheet: com.chethan616.clearpdf.utils.SpreadsheetParser.Sheet, + isDark: Boolean, + text: Color, + sub: Color, + accent: Color, + zoom: Float, + listState: androidx.compose.foundation.lazy.LazyListState, + matchSet: Set, + currentCell: Pair?, + onZoom: (Float) -> Unit, + onCellTap: (row: Int, col: Int, value: String) -> Unit, + modifier: Modifier = Modifier +) { + val colCount = sheet.columnCount.coerceAtLeast(1) + val hScroll = rememberScrollState() // shared β†’ header + all rows scroll horizontally in sync + val gridLine = if (isDark) Color.White.copy(0.10f) else Color.Black.copy(0.10f) + // Translucent now that the grid sits on glass. An opaque header read as a solid slab pasted on + // top of the panel and cut the refraction dead across the first row. + val headerBg = if (isDark) Color.White.copy(0.07f) else Color.Black.copy(0.05f) + val rowAlt = if (isDark) Color.White.copy(0.03f) else Color.Black.copy(0.02f) + val matchBg = accent.copy(0.20f) + val currentBg = accent.copy(0.48f) + val cellH = CELL_H * zoom + val headerSize = (11f * zoom).sp + val cellSize = (13f * zoom).sp + + // Per-column base widths sized to their content (sample up to 200 rows) so a short ID column + // stays narrow while a long text column gets room β€” no more uniform 132dp waste. + val baseWidths = remember(sheet) { + val sample = sheet.rows.take(200) + List(colCount) { c -> + var maxLen = colLetter(c).length + for (row in sample) { + val len = row.getOrNull(c)?.length ?: 0 + if (len > maxLen) maxLen = len + } + (maxLen.coerceAtMost(42) * 8 + 24).dp.coerceIn(64.dp, 260.dp) + } + } + + Column( + // No clip/border of its own any more β€” the glass panel it now sits inside is the container, + // and a 12 dp rounded outline inside a 28 dp glass capsule read as a box within a box. + modifier.fillMaxSize() + // Pinch-to-zoom (Apple-HIG). Only two-finger gestures are consumed, so single-finger + // scrolling still passes through to the row/column scroll. + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + do { + val e = awaitPointerEvent() + if (e.changes.count { it.pressed } >= 2) { + val z = e.calculateZoom() + if (z != 1f) { onZoom(z); e.changes.forEach { it.consume() } } + } + } while (e.changes.any { it.pressed }) + } + } + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + // If the content is narrower than the viewport, stretch every column proportionally so + // the grid fills the width (kills the empty right gap); wider sheets keep scrolling. + val available = maxWidth + val baseSum = baseWidths.fold(0.dp) { acc, w -> acc + w } + val fill = if (baseSum > 0.dp && baseSum < available) available / baseSum else 1f + val colWidths = baseWidths.map { it * fill * zoom } + + // Left edge of every column in dp, with the grid's total width parked at [colCount]. + val starts = remember(colWidths) { + val out = ArrayList(colCount + 1) + var acc = 0f + for (w in colWidths) { out.add(acc); acc += w.value } + out.add(acc) + out + } + // Horizontal windowing. `LazyColumn` keeps the row count in check, but each row was an + // eager `Row` over *every* column β€” a 150-column workbook is ~3000 cell nodes on screen, + // each with its own background, border, click handler and text layout. That does not + // throw; it just wedges the frame loop long enough to look like the app has died, and + // on a big enough sheet it is an ANR. Only the columns intersecting the viewport are + // composed now; the skipped ones on either side become a single spacer each, so the + // scroll range and every column's x-position are unchanged. + val scrolledDp = with(androidx.compose.ui.platform.LocalDensity.current) { hScroll.value.toDp().value } + val window = remember(starts, scrolledDp, available) { + val right = scrolledDp + available.value + var first = 0 + while (first < colCount - 1 && starts[first + 1] <= scrolledDp) first++ + var last = first + while (last < colCount - 1 && starts[last + 1] < right) last++ + first..last + } + val leadWidth = starts[window.first].dp + val tailWidth = (starts[colCount] - starts[window.last + 1]).dp + + Column(Modifier.fillMaxSize()) { + // Sticky column-letter header. + Row(Modifier.fillMaxWidth().background(headerBg).horizontalScroll(hScroll)) { + Spacer(Modifier.width(leadWidth)) + for (c in window) { + Box( + Modifier.width(colWidths[c]).heightIn(min = cellH).border(0.5.dp, gridLine).padding(horizontal = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicText(colLetter(c), style = TextStyle(sub, headerSize, FontWeight.Bold)) + } + } + Spacer(Modifier.width(tailWidth)) + } + LazyColumn(Modifier.fillMaxWidth().weight(1f), state = listState) { + itemsIndexed(sheet.rows) { rIdx, row -> + Row(Modifier.fillMaxWidth().horizontalScroll(hScroll)) { + Spacer(Modifier.width(leadWidth).heightIn(min = cellH)) + for (c in window) { + val v = row.getOrElse(c) { "" } + val isCurrent = currentCell?.first == rIdx && currentCell.second == c + val cellBg = when { + isCurrent -> currentBg + (rIdx.toLong() * 1_000_000L + c) in matchSet -> matchBg + rIdx % 2 == 1 -> rowAlt + else -> Color.Transparent + } + Box( + // Blank cells are tappable too β€” you have to be able to select an + // empty cell to type into it. + Modifier.width(colWidths[c]).heightIn(min = cellH).background(cellBg).border(0.5.dp, gridLine) + .clickable { onCellTap(rIdx, c, v) } + .padding(horizontal = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicText(v, style = TextStyle(text, cellSize), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Spacer(Modifier.width(tailWidth)) + } + } + } + } + } + } +} + +/** 0β†’A, 25β†’Z, 26β†’AA … spreadsheet column labels. */ +private fun colLetter(index: Int): String { + var i = index + val sb = StringBuilder() + while (i >= 0) { sb.insert(0, 'A' + (i % 26)); i = i / 26 - 1 } + return sb.toString() +} + +/** Share the (mirrored) file. file:// β†’ FileProvider content:// so it isn't exposed β†’ no crash. */ +private fun shareFile(context: android.content.Context, uri: android.net.Uri) { + val shareUri = if (uri.scheme == "file") { + runCatching { + androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", java.io.File(uri.path!!)) + }.getOrNull() ?: uri + } else uri + val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = context.contentResolver.getType(shareUri) ?: "application/octet-stream" + putExtra(android.content.Intent.EXTRA_STREAM, shareUri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + runCatching { context.startActivity(android.content.Intent.createChooser(intent, null)) } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ToolsScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ToolsScreen.kt index 7c05bbd..59cbbb3 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/ToolsScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/ToolsScreen.kt @@ -1,66 +1,82 @@ package com.chethan616.clearpdf.ui.screen +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition 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.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.BasicText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.CallMerge import androidx.compose.material.icons.automirrored.rounded.CallSplit import androidx.compose.material.icons.automirrored.rounded.NoteAdd +import androidx.compose.material.icons.rounded.BrandingWatermark +import androidx.compose.material.icons.rounded.Collections import androidx.compose.material.icons.rounded.Compress +import androidx.compose.material.icons.rounded.ContentCut +import androidx.compose.material.icons.rounded.EditNote import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Layers import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.LockOpen +import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material.icons.rounded.PhotoSizeSelectLarge import androidx.compose.material.icons.rounded.Reorder import androidx.compose.material.icons.rounded.TextSnippet -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.chethan616.clearpdf.ui.components.LiquidGlassCard -import com.chethan616.clearpdf.ui.components.LiquidGlassTopBar +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.GlassScreenScaffold +import com.chethan616.clearpdf.ui.components.GlassSearchHeader +import com.chethan616.clearpdf.ui.components.GlassSectionLabel +import com.chethan616.clearpdf.ui.components.ToolTile +import com.chethan616.clearpdf.ui.components.ToolTileWide +import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LiquidGlassColors import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.UISensor import com.chethan616.clearpdf.ui.utils.rememberUISensor import com.kyant.backdrop.backdrops.LayerBackdrop -import androidx.compose.ui.graphics.graphicsLayer - -import androidx.compose.ui.res.stringResource -import com.chethan616.clearpdf.R - private data class ToolSpec( val id: String, val title: String, val subtitle: String, val accent: Color, - val icon: ImageVector, + val icon: androidx.compose.ui.graphics.vector.ImageVector, val onClick: () -> Unit ) +private data class ToolSection(val label: String, val tools: List) + @Composable fun ToolsScreen( backdrop: LayerBackdrop, @@ -73,162 +89,254 @@ fun ToolsScreen( onNavigateToExtractText: () -> Unit = {}, onNavigateToImagesToPdf: () -> Unit = {}, onNavigateToDecryptPdf: () -> Unit = {}, - onNavigateToEncryptPdf: () -> Unit = {} + onNavigateToEncryptPdf: () -> Unit = {}, + onNavigateToPdfToImages: () -> Unit = {}, + onNavigateToWatermark: () -> Unit = {}, + onNavigateToExtractPages: () -> Unit = {}, + onNavigateToPageNumbers: () -> Unit = {}, + onNavigateToFlatten: () -> Unit = {}, + onNavigateToImageTools: () -> Unit = {}, + // Web/HTML to PDF is hidden in the privacy-focused release β€” its only networked feature is URL + // capture. The route and screen are kept; the entry point is simply not listed. + @Suppress("UNUSED_PARAMETER") onNavigateToHtmlToPdf: () -> Unit = {}, + onNavigateToFillForm: () -> Unit = {} ) { - val isDarkMode = LocalIsDarkMode.current val uiSensor = rememberUISensor() - val secondary = LiquidGlassColors.secondary(isDarkMode) - - val toolOpenPdfTitle = stringResource(R.string.tool_open_pdf) - val toolOpenPdfSub = stringResource(R.string.tool_open_pdf_sub) - val toolMergeTitle = stringResource(R.string.tool_merge) - val toolMergeSub = stringResource(R.string.tool_merge_sub) - val toolSplitTitle = stringResource(R.string.tool_split) - val toolSplitSub = stringResource(R.string.tool_split_sub) - val toolCompressTitle = stringResource(R.string.tool_compress) - val toolCompressSub = stringResource(R.string.tool_compress_sub) - val toolOrganizeTitle = stringResource(R.string.tool_organize) - val toolOrganizeSub = stringResource(R.string.tool_organize_sub) - val toolImagesTitle = stringResource(R.string.tool_images) - val toolImagesSub = stringResource(R.string.tool_images_sub) - val toolExtractTitle = stringResource(R.string.tool_extract) - val toolExtractSub = stringResource(R.string.tool_extract_sub) - val toolCreateTitle = stringResource(R.string.tool_create) - val toolCreateSub = stringResource(R.string.tool_create_sub) - val toolDecryptTitle = stringResource(R.string.tool_decrypt_pdf) - val toolDecryptSub = stringResource(R.string.tool_decrypt_pdf_sub) - val toolEncryptTitle = stringResource(R.string.tool_encrypt_pdf) - val toolEncryptSub = stringResource(R.string.tool_encrypt_pdf_sub) - - val toolItems = remember( - toolOpenPdfTitle, toolOpenPdfSub, - toolMergeTitle, toolMergeSub, - toolSplitTitle, toolSplitSub, - toolCompressTitle, toolCompressSub, - toolOrganizeTitle, toolOrganizeSub, - toolImagesTitle, toolImagesSub, - toolExtractTitle, toolExtractSub, - toolCreateTitle, toolCreateSub, - toolDecryptTitle, toolDecryptSub, - toolEncryptTitle, toolEncryptSub, - onNavigateToOpenPdf, - onNavigateToMergePdf, - onNavigateToSplitPdf, - onNavigateToCompressPdf, - onNavigateToOrganizePdf, - onNavigateToImagesToPdf, - onNavigateToExtractText, - onNavigateToCreatePdf, - onNavigateToDecryptPdf, - onNavigateToEncryptPdf - ) { - listOf( - ToolSpec("open", toolOpenPdfTitle, toolOpenPdfSub, LiquidGlassColors.Blue, Icons.Rounded.FileOpen, onNavigateToOpenPdf), - ToolSpec("merge", toolMergeTitle, toolMergeSub, LiquidGlassColors.Red, Icons.AutoMirrored.Rounded.CallMerge, onNavigateToMergePdf), - ToolSpec("split", toolSplitTitle, toolSplitSub, LiquidGlassColors.Purple, Icons.AutoMirrored.Rounded.CallSplit, onNavigateToSplitPdf), - ToolSpec("compress", toolCompressTitle, toolCompressSub, LiquidGlassColors.Green, Icons.Rounded.Compress, onNavigateToCompressPdf), - ToolSpec("organize", toolOrganizeTitle, toolOrganizeSub, LiquidGlassColors.Teal, Icons.Rounded.Reorder, onNavigateToOrganizePdf), - ToolSpec("images", toolImagesTitle, toolImagesSub, LiquidGlassColors.Indigo, Icons.Rounded.Image, onNavigateToImagesToPdf), - ToolSpec("extract", toolExtractTitle, toolExtractSub, Color(0xFF5AC8FA), Icons.Rounded.TextSnippet, onNavigateToExtractText), - ToolSpec("create", toolCreateTitle, toolCreateSub, LiquidGlassColors.Orange, Icons.AutoMirrored.Rounded.NoteAdd, onNavigateToCreatePdf), - ToolSpec("decrypt", toolDecryptTitle, toolDecryptSub, LiquidGlassColors.Purple, Icons.Rounded.LockOpen, onNavigateToDecryptPdf), - ToolSpec("encrypt", toolEncryptTitle, toolEncryptSub, LiquidGlassColors.Indigo, Icons.Rounded.Lock, onNavigateToEncryptPdf) - ) - } - - var isVisible by remember { mutableStateOf(false) } - androidx.compose.runtime.LaunchedEffect(Unit) { - isVisible = true - } + val isDarkMode = LocalIsDarkMode.current + val density = LocalDensity.current.density - val density = androidx.compose.ui.platform.LocalDensity.current.density + var query by remember { mutableStateOf("") } + var searchActive by remember { mutableStateOf(false) } - val topBarAlpha by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 550, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "toolsTopBarAlpha" - ) - val topBarOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 18f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 550, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "toolsTopBarOffsetY" + val openPdf = ToolSpec( + "open", stringResource(R.string.tool_open_pdf), stringResource(R.string.tool_open_pdf_sub), + LiquidGlassColors.Blue, Icons.Rounded.FileOpen, onNavigateToOpenPdf ) - val gridAlpha by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 1f else 0f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 650, delayMillis = 120, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "toolsGridAlpha" - ) - val gridOffsetY by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (isVisible) 0f else 26f, - animationSpec = androidx.compose.animation.core.tween(durationMillis = 650, delayMillis = 120, easing = androidx.compose.animation.core.FastOutSlowInEasing), - label = "toolsGridOffsetY" + // Built fresh each composition on purpose. The previous version memoised this behind a + // remember() with 34 dependency keys, which cost more to compare than these 17 small objects + // cost to allocate β€” and invalidated wholesale whenever any single callback changed identity. + val sections = listOf( + ToolSection( + stringResource(R.string.tools_section_organize), + listOf( + ToolSpec("merge", stringResource(R.string.tool_merge), stringResource(R.string.tool_merge_sub), LiquidGlassColors.Red, Icons.AutoMirrored.Rounded.CallMerge, onNavigateToMergePdf), + ToolSpec("split", stringResource(R.string.tool_split), stringResource(R.string.tool_split_sub), LiquidGlassColors.Purple, Icons.AutoMirrored.Rounded.CallSplit, onNavigateToSplitPdf), + ToolSpec("organize", stringResource(R.string.tool_organize), stringResource(R.string.tool_organize_sub), LiquidGlassColors.Teal, Icons.Rounded.Reorder, onNavigateToOrganizePdf), + ToolSpec("extract_pages", stringResource(R.string.tool_extract_pages), stringResource(R.string.tool_extract_pages_sub), Color(0xFF00897B), Icons.Rounded.ContentCut, onNavigateToExtractPages) + ) + ), + ToolSection( + stringResource(R.string.tools_section_convert), + listOf( + ToolSpec("images", stringResource(R.string.tool_images), stringResource(R.string.tool_images_sub), LiquidGlassColors.Indigo, Icons.Rounded.Image, onNavigateToImagesToPdf), + ToolSpec("pdf_to_images", stringResource(R.string.tool_pdf_to_images), stringResource(R.string.tool_pdf_to_images_sub), Color(0xFF00ACC1), Icons.Rounded.Collections, onNavigateToPdfToImages), + ToolSpec("extract", stringResource(R.string.tool_extract), stringResource(R.string.tool_extract_sub), Color(0xFF5AC8FA), Icons.Rounded.TextSnippet, onNavigateToExtractText), + ToolSpec("create", stringResource(R.string.tool_create), stringResource(R.string.tool_create_sub), LiquidGlassColors.Orange, Icons.AutoMirrored.Rounded.NoteAdd, onNavigateToCreatePdf) + ) + ), + ToolSection( + stringResource(R.string.tools_section_edit), + listOf( + ToolSpec("watermark", stringResource(R.string.tool_watermark), stringResource(R.string.tool_watermark_sub), Color(0xFFAD1457), Icons.Rounded.BrandingWatermark, onNavigateToWatermark), + ToolSpec("page_numbers", stringResource(R.string.tool_page_numbers), stringResource(R.string.tool_page_numbers_sub), Color(0xFF3949AB), Icons.Rounded.Numbers, onNavigateToPageNumbers), + ToolSpec("fill_form", stringResource(R.string.tool_fill_form), stringResource(R.string.tool_fill_form_sub), Color(0xFF00695C), Icons.Rounded.EditNote, onNavigateToFillForm), + ToolSpec("image_tools", stringResource(R.string.tool_image_tools), stringResource(R.string.tool_image_tools_sub), Color(0xFFF4511E), Icons.Rounded.PhotoSizeSelectLarge, onNavigateToImageTools) + ) + ), + ToolSection( + stringResource(R.string.tools_section_optimize), + listOf( + ToolSpec("compress", stringResource(R.string.tool_compress), stringResource(R.string.tool_compress_sub), LiquidGlassColors.Green, Icons.Rounded.Compress, onNavigateToCompressPdf), + ToolSpec("flatten", stringResource(R.string.tool_flatten), stringResource(R.string.tool_flatten_sub), Color(0xFF6D4C41), Icons.Rounded.Layers, onNavigateToFlatten), + ToolSpec("encrypt", stringResource(R.string.tool_encrypt_pdf), stringResource(R.string.tool_encrypt_pdf_sub), LiquidGlassColors.Indigo, Icons.Rounded.Lock, onNavigateToEncryptPdf), + ToolSpec("decrypt", stringResource(R.string.tool_decrypt_pdf), stringResource(R.string.tool_decrypt_pdf_sub), LiquidGlassColors.Purple, Icons.Rounded.LockOpen, onNavigateToDecryptPdf) + ) + ) ) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier - .fillMaxSize() - .statusBarsPadding(), - contentPadding = PaddingValues( - start = 16.dp, - top = 16.dp, - end = 16.dp, - bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 84.dp - ), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { isVisible = true } + // One transition, one frame clock. Sections stagger via delayMillis instead of each running its + // own animateFloatAsState. + val entrance = updateTransition(isVisible, label = "toolsEntrance") + + val trimmed = query.trim() + val searching = trimmed.isNotBlank() + val results = if (!searching) emptyList() else { + (listOf(openPdf) + sections.flatMap { it.tools }).filter { + it.title.contains(trimmed, ignoreCase = true) || it.subtitle.contains(trimmed, ignoreCase = true) + } + } + + GlassScreenScaffold( + backdrop = backdrop, + contentHorizontalPadding = 16.dp, + headerHorizontalPadding = 16.dp, + contentBottomPadding = 84.dp, + header = { headerBackdrop -> + // Holds a glass title pill and a glass circle, so it fades in place. Pinned above + // the list, sampling the content layer so the tiles refract through it as they scroll. + Box(entrance.glassFadeModifier(0)) { + GlassSearchHeader( + title = stringResource(R.string.tools_title), + backdrop = headerBackdrop, + uiSensor = uiSensor, + query = query, + onQueryChange = { query = it }, + active = searchActive, + onActiveChange = { searchActive = it }, + searchHint = stringResource(R.string.tools_search_hint) + ) + } + } + ) { contentPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(18.dp) ) { - item(span = { GridItemSpan(maxLineSpan) }) { - Box( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density + if (searching) { + item(key = "results") { + if (results.isEmpty()) { + BasicText( + stringResource(R.string.tools_no_matches), + style = TextStyle(LiquidGlassColors.secondary(isDarkMode), 14.sp), + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp) + ) + } else { + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + results.forEach { tool -> + ToolTileWide(tool.title, tool.subtitle, tool.accent, tool.icon, tool.onClick) + } + } + } + } + } else { + item(key = "primary") { + Box(entrance.tileEntranceModifier(0, density)) { + ToolTileWide(openPdf.title, openPdf.subtitle, openPdf.accent, openPdf.icon, openPdf.onClick) } - ) { - LiquidGlassTopBar(title = stringResource(R.string.tools_title), backdrop = backdrop, uiSensor = uiSensor) } - } - item(span = { GridItemSpan(maxLineSpan) }) { - Box( - Modifier.graphicsLayer { - alpha = topBarAlpha - translationY = topBarOffsetY * density + sections.forEachIndexed { index, section -> + item(key = section.label) { + // Five stagger slots per section β€” the label, then its four tiles β€” so the whole + // screen cascades top-to-bottom instead of four sections restarting in place. + val base = 1 + index * 5 + Column { + Box(entrance.tileEntranceModifier(base, density)) { + GlassSectionLabel(section.label) + } + ToolSectionPanel(section, backdrop, uiSensor, entrance, base, density) + } } - ) { - BasicText( - stringResource(R.string.tools_subtitle), - style = TextStyle( - color = secondary, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center - ), - modifier = Modifier.padding(vertical = 4.dp) - ) } } + } + } +} - items(toolItems, key = { it.id }) { tool -> - Box( - Modifier.graphicsLayer { - alpha = gridAlpha - translationY = gridOffsetY * density +/** + * One glass surface per section. The tiles inside are flat, so a section of four tools costs a + * single blur+lens pass rather than four. + */ +@Composable +private fun ToolSectionPanel( + section: ToolSection, + backdrop: LayerBackdrop, + uiSensor: UISensor, + entrance: Transition, + base: Int, + density: Float +) { + Column( + Modifier + .fillMaxWidth() + .then(entrance.glassFadeModifier(base)) + .liquidGlassPanel(backdrop, uiSensor) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + section.tools.chunked(2).forEachIndexed { rowIdx, pair -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + pair.forEachIndexed { colIdx, tool -> + // Flat index across both rows: the label took slot `base`, so the four tiles + // occupy base+1..base+4 and the cascade keeps running top-to-bottom. + ToolTile( + title = tool.title, + subtitle = tool.subtitle, + accent = tool.accent, + icon = tool.icon, + onClick = tool.onClick, + modifier = Modifier + .weight(1f) + .then(entrance.tileEntranceModifier(base + 1 + rowIdx * 2 + colIdx, density)) + ) } - ) { - LiquidGlassCard( - title = tool.title, - subtitle = tool.subtitle, - accentColor = tool.accent, - backdrop = backdrop, - uiSensor = uiSensor, - onClick = tool.onClick, - icon = { - Icon(tool.icon, contentDescription = tool.title, modifier = Modifier.size(26.dp), tint = tool.accent) - } - ) + // Keep a lone trailing tile at half width instead of letting it stretch. + if (pair.size == 1) Spacer(Modifier.weight(1f)) } } } } + +/** One stagger step. Everything on the screen is placed on this grid so the cascade reads evenly. */ +private const val StaggerStepMs = 35 + +/** + * Overshoots past 1.0 and settles back β€” the "bounce". It is only ever applied to scale and + * translation, which are draw-time properties, so the overshoot costs nothing beyond the frames it + * already takes. Alpha deliberately never gets this curve: an overshooting alpha clips at 1.0 and + * reads as a flicker rather than a bounce. + */ +private val EaseOutBack = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) + +/** + * Entrance for surfaces that contain liquid glass β€” the header pill and the section panels. + * + * **Alpha only, never translation.** A `drawBackdrop` surface samples the backdrop for the region it + * currently covers, so moving one re-runs blur+lens every single frame. Four section panels plus the + * header's glass pill and circle all sliding at once is what made this screen stutter. Holding them + * still keeps their sample region fixed for the whole entrance. + */ +@Composable +private fun Transition.glassFadeModifier(index: Int): Modifier { + val alpha by animateFloat( + transitionSpec = { tween(durationMillis = 320, delayMillis = StaggerStepMs * index, easing = FastOutSlowInEasing) }, + label = "glassFade$index" + ) { if (it) 1f else 0f } + return Modifier.graphicsLayer { this.alpha = alpha } +} + +/** + * Entrance for flat content β€” the tool tiles and the section labels. These have no `drawBackdrop`, + * so they are free to spring around: this is where the bounce lives. + * + * All three values are read inside the `graphicsLayer` lambda, which defers them to the draw phase, + * so the whole cascade invalidates draw without ever recomposing the screen. + */ +@Composable +private fun Transition.tileEntranceModifier(index: Int, density: Float): Modifier { + val scale by animateFloat( + transitionSpec = { tween(durationMillis = 420, delayMillis = StaggerStepMs * index, easing = EaseOutBack) }, + label = "tileScale$index" + ) { if (it) 1f else 0.86f } + val offsetY by animateFloat( + transitionSpec = { tween(durationMillis = 420, delayMillis = StaggerStepMs * index, easing = EaseOutBack) }, + label = "tileOffset$index" + ) { if (it) 0f else 18f } + val alpha by animateFloat( + transitionSpec = { tween(durationMillis = 260, delayMillis = StaggerStepMs * index, easing = FastOutSlowInEasing) }, + label = "tileAlpha$index" + ) { if (it) 1f else 0f } + return Modifier.graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + translationY = offsetY * density + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/WatermarkPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/WatermarkPdfScreen.kt new file mode 100644 index 0000000..786aecb --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/WatermarkPdfScreen.kt @@ -0,0 +1,253 @@ +package com.chethan616.clearpdf.ui.screen + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BrandingWatermark +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.UploadFile +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.GlassChip +import com.chethan616.clearpdf.ui.components.GlassSectionHeader +import com.chethan616.clearpdf.ui.components.LiquidButton +import com.chethan616.clearpdf.ui.components.LiquidGlassErrorCard +import com.chethan616.clearpdf.ui.components.LiquidSlider +import com.chethan616.clearpdf.ui.components.LiquidToggle +import com.chethan616.clearpdf.ui.components.ToolScaffold +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.ui.viewmodel.WatermarkMode +import com.chethan616.clearpdf.ui.viewmodel.WatermarkPdfViewModel +import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay + +private val WatermarkAccent = Color(0xFFAD1457) + +@Composable +fun WatermarkPdfScreen( + backdrop: LayerBackdrop, + viewModel: WatermarkPdfViewModel, + onBack: () -> Unit, + onViewOutput: (Uri) -> Unit +) { + val state by viewModel.uiState.collectAsState() + val isDark = LocalIsDarkMode.current + val isLight = !isDark + val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) + val sub = if (isLight) Color(0xFF888888) else Color(0xFFAAAAAA) + val uiSensor = rememberUISensor() + val context = LocalContext.current + + LaunchedEffect(state.resultMessage, state.errorMessage) { + if (state.resultMessage != null || state.errorMessage != null) { + delay(3500); viewModel.clearFeedback() + } + } + + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> if (uri != null) viewModel.onSelectFile(context, uri) } + + val imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> if (uri != null) viewModel.onPickImage(context, uri) } + + ToolScaffold( + title = stringResource(R.string.tool_watermark), + backdrop = backdrop, + onBack = onBack + ) { + // Intro / pick card + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon(Icons.Rounded.BrandingWatermark, null, Modifier.size(56.dp), WatermarkAccent) + BasicText(stringResource(R.string.tool_watermark), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.tool_watermark_sub), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + LiquidButton(onClick = { filePicker.launch(arrayOf("application/pdf")) }, backdrop = backdrop, tint = WatermarkAccent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + } + + if (state.sourceUri != null) { + // File name + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + BasicText(state.sourceName, style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium), maxLines = 1) + } + + // Options card + Column( + Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + GlassSectionHeader( + title = stringResource(R.string.watermark_text_label), + icon = Icons.Rounded.BrandingWatermark, + iconTint = WatermarkAccent, + titleColor = text + ) + + // Mode: Text / Image + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WmModeOption(stringResource(R.string.watermark_mode_text), state.mode == WatermarkMode.TEXT, backdrop, isLight, { viewModel.onModeChange(WatermarkMode.TEXT) }, Modifier.weight(1f)) + WmModeOption(stringResource(R.string.watermark_mode_image), state.mode == WatermarkMode.IMAGE, backdrop, isLight, { viewModel.onModeChange(WatermarkMode.IMAGE) }, Modifier.weight(1f)) + } + + if (state.mode == WatermarkMode.TEXT) { + BasicTextField( + value = state.text, + onValueChange = viewModel::onTextChange, + singleLine = true, + textStyle = TextStyle(text, 15.sp), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (isDark) Color.White.copy(0.08f) else Color.Black.copy(0.05f)) + .padding(horizontal = 12.dp, vertical = 12.dp), + decorationBox = { inner -> + if (state.text.isEmpty()) BasicText(stringResource(R.string.watermark_text_hint), style = TextStyle(sub, 14.sp)) + inner() + } + ) + } else { + LiquidButton(onClick = { imagePicker.launch("image/*") }, backdrop = backdrop, tint = WatermarkAccent, modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp)) { + Icon(Icons.Rounded.Image, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.watermark_pick_image), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + } + } + if (state.imageName.isNotEmpty()) BasicText(state.imageName, style = TextStyle(sub, 12.sp), maxLines = 1) + } + + // Opacity + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + BasicText(stringResource(R.string.watermark_opacity), style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) + GlassChip("${(state.opacity * 100).toInt()}%", WatermarkAccent) + } + LiquidSlider( + value = { state.opacity }, + onValueChange = viewModel::onOpacityChange, + valueRange = 0.05f..1f, + visibilityThreshold = 0.005f, + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) + + // Diagonal toggle + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + BasicText(stringResource(R.string.watermark_diagonal), style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) + LiquidToggle(selected = { state.diagonal }, onSelect = viewModel::onDiagonalChange, backdrop = backdrop) + } + } + + // Apply + LiquidButton( + onClick = { if (!state.isProcessing) viewModel.apply(context) }, + backdrop = backdrop, tint = WatermarkAccent, + modifier = Modifier.fillMaxWidth() + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + if (state.isProcessing) { + CircularProgressIndicator(Modifier.size(18.dp), Color.White, strokeWidth = 2.dp) + } else { + Icon(Icons.Rounded.BrandingWatermark, null, Modifier.size(18.dp), Color.White) + } + BasicText( + stringResource(if (state.isProcessing) R.string.watermark_applying else R.string.watermark_apply), + style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium), + maxLines = 1 + ) + } + } + } + + if (state.errorMessage != null) { + LiquidGlassErrorCard( + message = state.errorMessage!!, + backdrop = backdrop, + uiSensor = uiSensor, + onDismiss = { viewModel.clearFeedback() } + ) + } + + state.lastOutputUri?.let { outUri -> + LiquidButton( + onClick = { onViewOutput(outUri) }, + backdrop = backdrop, tint = Color(0xFF1976D2), + modifier = Modifier.fillMaxWidth() + ) { + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold), modifier = Modifier.padding(vertical = 8.dp)) + } + } + + Spacer(Modifier.height(40.dp)) + } +} + +@Composable +private fun WmModeOption( + label: String, + selected: Boolean, + backdrop: LayerBackdrop, + isLight: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val contentColor = if (selected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) + LiquidButton( + onClick = onClick, + backdrop = backdrop, + tint = if (selected) WatermarkAccent else Color.Transparent, + surfaceColor = if (selected) WatermarkAccent.copy(0.18f) else (if (isLight) Color.White.copy(0.70f) else Color.White.copy(0.10f)), + modifier = modifier + ) { + BasicText( + label, + style = TextStyle(contentColor, 14.sp, fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium), + modifier = Modifier.padding(vertical = 4.dp) + ) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/utils/LocaleHelper.kt b/app/src/main/java/com/chethan616/clearpdf/ui/utils/LocaleHelper.kt index f53d9c3..dbc0829 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/utils/LocaleHelper.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/utils/LocaleHelper.kt @@ -10,6 +10,39 @@ import java.util.Locale object LocaleHelper { + private const val PREFS_NAME = "clearpdf_settings" + private const val KEY_PENDING_FADE = "pending_locale_fade" + + /** + * A locale change has to restart the Activity β€” 15 call sites read strings outside Compose, so a + * Compose-only swap would leave half the app in the old language. The restart is therefore kept + * and *choreographed* instead: the outgoing instance fades out, and this one-shot flag tells the + * incoming one to fade in rather than snap. Written on the way out, consumed on the way in. + */ + fun markLocaleFadePending(context: Context) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putBoolean(KEY_PENDING_FADE, true).apply() + } + + /** Reads the flag and clears it, so a later recreate (rotation, theme) doesn't re-fade. */ + fun consumeLocaleFadePending(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val pending = prefs.getBoolean(KEY_PENDING_FADE, false) + if (pending) prefs.edit().remove(KEY_PENDING_FADE).apply() + return pending + } + + /** Kills the OS's own restart cross-fade so it can't stack on ours. */ + fun suppressActivityTransition(activity: android.app.Activity) { + if (Build.VERSION.SDK_INT >= 34) { + activity.overrideActivityTransition(android.app.Activity.OVERRIDE_TRANSITION_OPEN, 0, 0) + activity.overrideActivityTransition(android.app.Activity.OVERRIDE_TRANSITION_CLOSE, 0, 0) + } else { + @Suppress("DEPRECATION") + activity.overridePendingTransition(0, 0) + } + } + private fun normalizeLanguageTag(languageTag: String): String { val locale = Locale.forLanguageTag(languageTag.replace('_', '-')) return if (locale.language.equals("pt", ignoreCase = true)) "pt-BR" else "en" diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CompressPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CompressPdfViewModel.kt index 8d770d1..72d862d 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CompressPdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CompressPdfViewModel.kt @@ -10,6 +10,7 @@ import android.provider.MediaStore import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.AppSettingsManager import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager import com.chethan616.clearpdf.data.repository.RecentFile import com.chethan616.clearpdf.data.repository.RecentFilesManager @@ -52,6 +53,15 @@ class CompressPdfViewModel(private val compressPdfUseCase: CompressPdfUseCase) : fun onSelectFile(context: Context, uri: Uri) { viewModelScope.launch { try { + // Honor the user's Default Quality setting instead of always starting on MEDIUM. + // Mapped with the same thresholds the slider uses (see onQualitySliderChanged), and + // the slider seeds to the raw stored value so its knob lands where the user set it. + val defaultQuality = AppSettingsManager.getDefaultQuality(context) + val quality = when { + defaultQuality < 0.33f -> CompressionQuality.LOW + defaultQuality < 0.66f -> CompressionQuality.MEDIUM + else -> CompressionQuality.HIGH + } val (name, size, estimate) = withContext(AppDispatchers.pdf) { try { context.contentResolver.takePersistableUriPermission( @@ -62,14 +72,15 @@ class CompressPdfViewModel(private val compressPdfUseCase: CompressPdfUseCase) : val fileSize = context.contentResolver.openFileDescriptor(uri, "r") ?.use { it.statSize } ?: -1L val source = PdfDocument(uri = uri, name = fileName, sizeBytes = fileSize) - val est = compressPdfUseCase.estimateSize(source, CompressionQuality.MEDIUM) + val est = compressPdfUseCase.estimateSize(source, quality) Triple(fileName, fileSize, est) } _uiState.value = CompressPdfUiState( sourceFileName = name, sourceUri = uri, originalSizeBytes = size, - qualitySlider = 0.5f, + selectedQuality = quality, + qualitySlider = defaultQuality, estimatedSizeBytes = estimate ) } catch (e: Exception) { diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractPagesViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractPagesViewModel.kt new file mode 100644 index 0000000..7fd3090 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractPagesViewModel.kt @@ -0,0 +1,163 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.RecentFile +import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.data.repository.SaveLocationManager +import com.kyant.pdfcore.model.PdfDocument +import com.kyant.pdfcore.splitter.PdfSplitterImpl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class ExtractPagesUiState( + val sourceUri: Uri? = null, + val sourceName: String = "", + val pageCount: Int = 0, + val rangeText: String = "", + val isProcessing: Boolean = false, + val lastOutputUri: Uri? = null, + val resultMessage: String? = null, + val errorMessage: String? = null +) + +class ExtractPagesViewModel : ViewModel() { + + private val splitter = PdfSplitterImpl() + private val _uiState = MutableStateFlow(ExtractPagesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onSelectFile(context: Context, uri: Uri) { + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + viewModelScope.launch { + val name = queryName(context, uri) + val count = withContext(Dispatchers.IO) { runCatching { pageCount(context, uri) }.getOrDefault(0) } + _uiState.update { + it.copy( + sourceUri = uri, sourceName = name, pageCount = count, + rangeText = if (count > 0) "1-$count" else "", + lastOutputUri = null, resultMessage = null, + errorMessage = if (count == 0) "Couldn't read this PDF" else null + ) + } + } + } + + fun onRangeChange(value: String) = _uiState.update { it.copy(rangeText = value) } + + fun apply(context: Context) { + val src = _uiState.value.sourceUri ?: return + if (_uiState.value.isProcessing) return + val pages = parsePageRanges(_uiState.value.rangeText, _uiState.value.pageCount) + if (pages.isEmpty()) { + _uiState.update { it.copy(errorMessage = "Enter valid pages (e.g. 1-3, 5)") } + return + } + _uiState.update { it.copy(isProcessing = true, errorMessage = null, resultMessage = null, lastOutputUri = null) } + viewModelScope.launch { + try { + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val fileName = "ClearPDF_Extracted_$ts.pdf" + val saveLabel = SaveLocationManager.getSavePathDisplay(context) + val outUri = createOutputUri(context, fileName) + val source = PdfDocument(uri = src, name = _uiState.value.sourceName, pageCount = _uiState.value.pageCount) + withContext(Dispatchers.IO) { splitter.extractPages(context, source, pages, outUri) } + RecentFilesManager.addRecent(context, RecentFile( + name = fileName, uriString = outUri.toString(), + timestamp = System.currentTimeMillis(), sizeBytes = 0 + )) + _uiState.update { + it.copy( + isProcessing = false, lastOutputUri = outUri, + resultMessage = "Extracted ${pages.size} page${if (pages.size == 1) "" else "s"} Β· saved to $saveLabel" + ) + } + } catch (t: Throwable) { + _uiState.update { it.copy(isProcessing = false, errorMessage = t.message ?: "Couldn't extract pages") } + } + } + } + + fun clearFeedback() = _uiState.update { it.copy(errorMessage = null, resultMessage = null) } + + /** Parse a 1-based range string like "1-3, 5, 8-10" into sorted, unique, 0-based indices. */ + private fun parsePageRanges(input: String, pageCount: Int): List { + if (pageCount <= 0) return emptyList() + val out = sortedSetOf() + input.split(",").forEach { raw -> + val part = raw.trim() + if (part.isEmpty()) return@forEach + if (part.contains("-")) { + val bounds = part.split("-") + val a = bounds.getOrNull(0)?.trim()?.toIntOrNull() + val b = bounds.getOrNull(1)?.trim()?.toIntOrNull() + if (a != null && b != null) { + for (n in minOf(a, b)..maxOf(a, b)) if (n in 1..pageCount) out.add(n - 1) + } + } else { + part.toIntOrNull()?.let { if (it in 1..pageCount) out.add(it - 1) } + } + } + return out.toList() + } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: uri.lastPathSegment ?: "document.pdf" + + private fun pageCount(context: Context, uri: Uri): Int { + val pfd: ParcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r") ?: return 0 + return pfd.use { PdfRenderer(it).use { r -> r.pageCount } } + } + + private fun createOutputUri(context: Context, fileName: String): Uri { + val customUri = SaveLocationManager.getSaveUri(context) + if (customUri != null) { + return try { + val docUri = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, customUri) + docUri?.createFile("application/pdf", fileName)?.uri ?: createDownloadUri(context, fileName) + } catch (_: Exception) { createDownloadUri(context, fileName) } + } + return createDownloadUri(context, fileName) + } + + private fun createDownloadUri(context: Context, fileName: String): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cv = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) + ?: throw IllegalStateException("Unable to create output in Downloads") + } else { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + if (!dir.exists()) dir.mkdirs() + val file = java.io.File(dir, fileName) + if (!file.exists()) file.createNewFile() + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/FillFormViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/FillFormViewModel.kt new file mode 100644 index 0000000..9c48c9c --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/FillFormViewModel.kt @@ -0,0 +1,129 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.RecentFile +import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.data.repository.SaveLocationManager +import com.kyant.pdfcore.form.PdfFormService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class FillFormUiState( + val sourceUri: Uri? = null, + val sourceName: String = "", + val fields: List = emptyList(), + val loaded: Boolean = false, + val flatten: Boolean = false, + val isProcessing: Boolean = false, + val lastOutputUri: Uri? = null, + val resultMessage: String? = null, + val errorMessage: String? = null +) + +class FillFormViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(FillFormUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onSelectFile(context: Context, uri: Uri) { + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + _uiState.update { it.copy(sourceUri = uri, sourceName = queryName(context, uri), fields = emptyList(), loaded = false, lastOutputUri = null, resultMessage = null, errorMessage = null) } + viewModelScope.launch { + val fields = withContext(Dispatchers.IO) { runCatching { PdfFormService.readFields(context, uri) }.getOrDefault(emptyList()) } + _uiState.update { + it.copy( + fields = fields, loaded = true, + errorMessage = if (fields.isEmpty()) "This PDF has no fillable form fields" else null + ) + } + } + } + + fun onFieldChange(name: String, value: String) { + _uiState.update { st -> + st.copy(fields = st.fields.map { if (it.name == name) it.copy(value = value) else it }) + } + } + + fun onFlattenChange(value: Boolean) = _uiState.update { it.copy(flatten = value) } + + fun save(context: Context) { + val src = _uiState.value.sourceUri ?: return + if (_uiState.value.isProcessing || _uiState.value.fields.isEmpty()) return + _uiState.update { it.copy(isProcessing = true, errorMessage = null, resultMessage = null, lastOutputUri = null) } + viewModelScope.launch { + try { + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val fileName = "ClearPDF_Filled_$ts.pdf" + val saveLabel = SaveLocationManager.getSavePathDisplay(context) + val outUri = withContext(Dispatchers.IO) { createOutputUri(context, fileName) } + val values = _uiState.value.fields.associate { it.name to it.value } + val flatten = _uiState.value.flatten + val written = withContext(Dispatchers.IO) { PdfFormService.fill(context, src, outUri, values, flatten) } + RecentFilesManager.addRecent(context, RecentFile( + name = fileName, uriString = outUri.toString(), + timestamp = System.currentTimeMillis(), sizeBytes = 0 + )) + _uiState.update { it.copy(isProcessing = false, lastOutputUri = outUri, resultMessage = "Filled $written field${if (written == 1) "" else "s"} Β· saved to $saveLabel") } + } catch (t: Throwable) { + _uiState.update { it.copy(isProcessing = false, errorMessage = t.message ?: "Couldn't fill the form") } + } + } + } + + fun clearFeedback() = _uiState.update { it.copy(errorMessage = null, resultMessage = null) } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: uri.lastPathSegment ?: "document.pdf" + + private fun createOutputUri(context: Context, fileName: String): Uri { + val customUri = SaveLocationManager.getSaveUri(context) + if (customUri != null) { + return try { + val docUri = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, customUri) + docUri?.createFile("application/pdf", fileName)?.uri ?: createDownloadUri(context, fileName) + } catch (_: Exception) { createDownloadUri(context, fileName) } + } + return createDownloadUri(context, fileName) + } + + private fun createDownloadUri(context: Context, fileName: String): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cv = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) + ?: throw IllegalStateException("Unable to create output in Downloads") + } else { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + if (!dir.exists()) dir.mkdirs() + val file = java.io.File(dir, fileName) + if (!file.exists()) file.createNewFile() + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/FlattenPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/FlattenPdfViewModel.kt new file mode 100644 index 0000000..55dfecc --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/FlattenPdfViewModel.kt @@ -0,0 +1,112 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.RecentFile +import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.data.repository.SaveLocationManager +import com.kyant.pdfcore.flatten.PdfFlattener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class FlattenUiState( + val sourceUri: Uri? = null, + val sourceName: String = "", + val isProcessing: Boolean = false, + val lastOutputUri: Uri? = null, + val resultMessage: String? = null, + val errorMessage: String? = null +) + +class FlattenPdfViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(FlattenUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onSelectFile(context: Context, uri: Uri) { + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + _uiState.update { + it.copy(sourceUri = uri, sourceName = queryName(context, uri), lastOutputUri = null, resultMessage = null, errorMessage = null) + } + } + + fun apply(context: Context) { + val src = _uiState.value.sourceUri ?: return + if (_uiState.value.isProcessing) return + _uiState.update { it.copy(isProcessing = true, errorMessage = null, resultMessage = null, lastOutputUri = null) } + viewModelScope.launch { + try { + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val fileName = "ClearPDF_Flattened_$ts.pdf" + val saveLabel = SaveLocationManager.getSavePathDisplay(context) + val outUri = createOutputUri(context, fileName) + val fields = withContext(Dispatchers.IO) { PdfFlattener.flatten(context, src, outUri) } + RecentFilesManager.addRecent(context, RecentFile( + name = fileName, uriString = outUri.toString(), + timestamp = System.currentTimeMillis(), sizeBytes = 0 + )) + val msg = if (fields > 0) + "Flattened $fields field${if (fields == 1) "" else "s"} Β· saved to $saveLabel" + else "No form fields β€” saved a flattened copy to $saveLabel" + _uiState.update { it.copy(isProcessing = false, lastOutputUri = outUri, resultMessage = msg) } + } catch (t: Throwable) { + _uiState.update { it.copy(isProcessing = false, errorMessage = t.message ?: "Couldn't flatten PDF") } + } + } + } + + fun clearFeedback() = _uiState.update { it.copy(errorMessage = null, resultMessage = null) } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: uri.lastPathSegment ?: "document.pdf" + + private fun createOutputUri(context: Context, fileName: String): Uri { + val customUri = SaveLocationManager.getSaveUri(context) + if (customUri != null) { + return try { + val docUri = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, customUri) + docUri?.createFile("application/pdf", fileName)?.uri ?: createDownloadUri(context, fileName) + } catch (_: Exception) { createDownloadUri(context, fileName) } + } + return createDownloadUri(context, fileName) + } + + private fun createDownloadUri(context: Context, fileName: String): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cv = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) + ?: throw IllegalStateException("Unable to create output in Downloads") + } else { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + if (!dir.exists()) dir.mkdirs() + val file = java.io.File(dir, fileName) + if (!file.exists()) file.createNewFile() + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/HtmlToPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/HtmlToPdfViewModel.kt new file mode 100644 index 0000000..b623b02 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/HtmlToPdfViewModel.kt @@ -0,0 +1,146 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.RecentFile +import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.data.repository.SaveLocationManager +import com.chethan616.clearpdf.util.HtmlToPdfConverter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.coroutines.resume + +enum class WebToPdfMode { URL, HTML } + +data class HtmlToPdfUiState( + val mode: WebToPdfMode = WebToPdfMode.URL, + val url: String = "", + val html: String = "", + val sourceName: String = "", + val isProcessing: Boolean = false, + val lastOutputUri: Uri? = null, + val resultMessage: String? = null, + val errorMessage: String? = null +) + +class HtmlToPdfViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(HtmlToPdfUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onModeChange(mode: WebToPdfMode) = _uiState.update { it.copy(mode = mode, lastOutputUri = null, resultMessage = null, errorMessage = null) } + fun onUrlChange(value: String) = _uiState.update { it.copy(url = value) } + fun onHtmlChange(value: String) = _uiState.update { it.copy(html = value) } + + fun onLoadFile(context: Context, uri: Uri) { + viewModelScope.launch { + val name = queryName(context, uri) + val content = withContext(Dispatchers.IO) { + runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) } }.getOrNull() + } + if (content == null) { + _uiState.update { it.copy(errorMessage = "Couldn't read that file") } + } else { + _uiState.update { it.copy(html = content, sourceName = name, lastOutputUri = null, resultMessage = null, errorMessage = null) } + } + } + } + + fun convert(context: Context) { + if (_uiState.value.isProcessing) return + val mode = _uiState.value.mode + val rawUrl = _uiState.value.url.trim() + if (mode == WebToPdfMode.URL && rawUrl.isBlank()) { + _uiState.update { it.copy(errorMessage = "Enter a web address") } + return + } + if (mode == WebToPdfMode.HTML && _uiState.value.html.isBlank()) { + _uiState.update { it.copy(errorMessage = "Enter or load some HTML") } + return + } + // Default the scheme so "example.com" works. + val url = if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) rawUrl else "https://$rawUrl" + _uiState.update { it.copy(isProcessing = true, errorMessage = null, resultMessage = null, lastOutputUri = null) } + viewModelScope.launch { + try { + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val fileName = "ClearPDF_Web_$ts.pdf" + val saveLabel = SaveLocationManager.getSavePathDisplay(context) + val outUri = withContext(Dispatchers.IO) { createOutputUri(context, fileName) } + val html = _uiState.value.html + // WebView work must run on the main thread. + val ok = withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + val cb: (Boolean) -> Unit = { success -> if (cont.isActive) cont.resume(success) } + if (mode == WebToPdfMode.URL) HtmlToPdfConverter.convertUrl(context, url, outUri, cb) + else HtmlToPdfConverter.convertHtml(context, html, outUri, cb) + } + } + if (ok) { + RecentFilesManager.addRecent(context, RecentFile( + name = fileName, uriString = outUri.toString(), + timestamp = System.currentTimeMillis(), sizeBytes = 0 + )) + _uiState.update { it.copy(isProcessing = false, lastOutputUri = outUri, resultMessage = "PDF created Β· saved to $saveLabel") } + } else { + _uiState.update { it.copy(isProcessing = false, errorMessage = "Couldn't create the PDF") } + } + } catch (t: Throwable) { + _uiState.update { it.copy(isProcessing = false, errorMessage = t.message ?: "Couldn't convert HTML") } + } + } + } + + fun clearFeedback() = _uiState.update { it.copy(errorMessage = null, resultMessage = null) } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: uri.lastPathSegment ?: "document.html" + + private fun createOutputUri(context: Context, fileName: String): Uri { + val customUri = SaveLocationManager.getSaveUri(context) + if (customUri != null) { + return try { + val docUri = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, customUri) + docUri?.createFile("application/pdf", fileName)?.uri ?: createDownloadUri(context, fileName) + } catch (_: Exception) { createDownloadUri(context, fileName) } + } + return createDownloadUri(context, fileName) + } + + private fun createDownloadUri(context: Context, fileName: String): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cv = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) + ?: throw IllegalStateException("Unable to create output in Downloads") + } else { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + if (!dir.exists()) dir.mkdirs() + val file = java.io.File(dir, fileName) + if (!file.exists()) file.createNewFile() + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImageEditorViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImageEditorViewModel.kt new file mode 100644 index 0000000..be4e53a --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImageEditorViewModel.kt @@ -0,0 +1,224 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Matrix +import android.graphics.Paint +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Real image editing on the ORIGINAL image: rotate, colour filters, brightness/contrast β†’ save. */ +class ImageEditorViewModel : ViewModel() { + + enum class ImgFilter { None, Mono, Sepia, Vivid, Cool, Warm } + + data class UiState( + val fileName: String = "", + val preview: Bitmap? = null, // downsampled, rotation already baked in + val rotation: Int = 0, // 0 / 90 / 180 / 270 (relative to source) + val filter: ImgFilter = ImgFilter.None, + val brightness: Float = 0f, // -100..100 + val contrast: Float = 1f, // 0.5..2.0 (1 = none) + val isLoading: Boolean = true, + val error: String? = null, + val savedMessage: String? = null + ) + + private val _state = MutableStateFlow(UiState()) + val state = _state.asStateFlow() + + private var sourceUri: Uri? = null + private var basePreview: Bitmap? = null // unrotated, downsampled source + private var started = false + + fun load(context: Context, uri: Uri) { + if (started) return + started = true + sourceUri = uri + viewModelScope.launch { + val name = withContext(Dispatchers.IO) { queryName(context, uri) } + val bmp = withContext(Dispatchers.IO) { decodeDownsampled(context, uri, 1600) } + basePreview = bmp + _state.value = if (bmp == null) { + UiState(fileName = name, isLoading = false, error = "Couldn't open this image.") + } else { + UiState(fileName = name, preview = bmp, isLoading = false) + } + } + } + + fun rotate(deltaDeg: Int) { + val s = _state.value + val base = basePreview ?: return + val newRot = ((s.rotation + deltaDeg) % 360 + 360) % 360 + val rotated = if (newRot == 0) base else rotateBitmap(base, newRot.toFloat()) + _state.value = s.copy(rotation = newRot, preview = rotated) + } + + fun setFilter(f: ImgFilter) { _state.value = _state.value.copy(filter = f) } + fun setBrightness(v: Float) { _state.value = _state.value.copy(brightness = v.coerceIn(-100f, 100f)) } + fun setContrast(v: Float) { _state.value = _state.value.copy(contrast = v.coerceIn(0.5f, 2f)) } + fun reset() { _state.value = _state.value.copy(filter = ImgFilter.None, brightness = 0f, contrast = 1f) } + + fun dismissMessage() { _state.value = _state.value.copy(savedMessage = null) } + + /** Render the FULL-resolution edited image and save it to the gallery. */ + fun saveToGallery(context: Context) { + val uri = sourceUri ?: return + val s = _state.value + viewModelScope.launch { + val ok = withContext(Dispatchers.IO) { + runCatching { + val full = decodeDownsampled(context, uri, 4096) ?: return@runCatching false + val edited = renderEdited(full, s) + val saved = saveBitmap(context, edited, s.fileName) + edited.recycle(); if (edited != full) full.recycle() + saved + }.getOrDefault(false) + } + _state.value = _state.value.copy(savedMessage = if (ok) "Saved to gallery" else "Couldn't save image") + } + } + + /** Render the FULL-resolution edited image into a single-page PDF and hand back its URI. */ + fun exportToPdf(context: Context, onDone: (Uri?) -> Unit) { + val uri = sourceUri ?: return onDone(null) + val s = _state.value + viewModelScope.launch { + val out = withContext(Dispatchers.IO) { + runCatching { + val full = decodeDownsampled(context, uri, 4096) ?: return@runCatching null + val edited = renderEdited(full, s) + val pdf = android.graphics.pdf.PdfDocument() + val page = pdf.startPage(android.graphics.pdf.PdfDocument.PageInfo.Builder(edited.width, edited.height, 1).create()) + page.canvas.drawBitmap(edited, 0f, 0f, null) + pdf.finishPage(page) + val dir = java.io.File(context.cacheDir, "converted_pdfs").apply { mkdirs() } + val file = java.io.File(dir, "Image_${System.currentTimeMillis()}.pdf") + java.io.FileOutputStream(file).use { pdf.writeTo(it) } + pdf.close(); edited.recycle(); if (edited != full) full.recycle() + Uri.fromFile(file) + }.getOrNull() + } + onDone(out) + } + } + + // ── rendering ──────────────────────────────────────────────────────────────── + + /** Apply rotation + colour matrix to a bitmap. Compose preview applies the colour matrix live, + * so this is only used for the exported/saved output. */ + private fun renderEdited(src: Bitmap, s: UiState): Bitmap { + val rotated = if (s.rotation == 0) src else rotateBitmap(src, s.rotation.toFloat()) + val out = Bitmap.createBitmap(rotated.width, rotated.height, Bitmap.Config.ARGB_8888) + val paint = Paint(Paint.FILTER_BITMAP_FLAG).apply { + colorFilter = ColorMatrixColorFilter(ColorMatrix(colorMatrixFor(s.filter, s.brightness, s.contrast))) + } + Canvas(out).drawBitmap(rotated, 0f, 0f, paint) + if (rotated != src) rotated.recycle() + return out + } + + private fun rotateBitmap(src: Bitmap, deg: Float): Bitmap = + Bitmap.createBitmap(src, 0, 0, src.width, src.height, Matrix().apply { postRotate(deg) }, true) + + private fun saveBitmap(context: Context, bmp: Bitmap, name: String): Boolean { + val base = name.substringBeforeLast('.').ifBlank { "image" } + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "${base}_edited_${System.currentTimeMillis()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/ClearPDF") + } + val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + else MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val item = context.contentResolver.insert(collection, values) ?: return false + return runCatching { + context.contentResolver.openOutputStream(item)?.use { out -> bmp.compress(Bitmap.CompressFormat.JPEG, 92, out) } + true + }.getOrDefault(false) + } + + private fun decodeDownsampled(context: Context, uri: Uri, maxDim: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + val w = bounds.outWidth; val h = bounds.outHeight + if (w <= 0 || h <= 0) return null + var sample = 1 + while (w / (sample * 2) >= maxDim || h / (sample * 2) >= maxDim) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + return context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { c -> + val i = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (i != -1 && c.moveToFirst()) c.getString(i) else null + } ?: uri.lastPathSegment ?: "Image" + + companion object { + /** Android [ColorMatrix] float[20] for a filter + brightness (-100..100) + contrast (0.5..2). */ + fun colorMatrixFor(filter: ImgFilter, brightness: Float, contrast: Float): FloatArray { + val m = ColorMatrix() + when (filter) { + ImgFilter.None -> {} + ImgFilter.Mono -> m.setSaturation(0f) + ImgFilter.Sepia -> { + m.setSaturation(0f) + m.postConcat(ColorMatrix(floatArrayOf( + 1.0f, 0f, 0f, 0f, 40f, + 0f, 0.95f, 0f, 0f, 20f, + 0f, 0f, 0.82f, 0f, 0f, + 0f, 0f, 0f, 1f, 0f + ))) + } + ImgFilter.Vivid -> m.setSaturation(1.6f) + ImgFilter.Cool -> m.postConcat(ColorMatrix(floatArrayOf( + 0.95f, 0f, 0f, 0f, 0f, + 0f, 1.0f, 0f, 0f, 0f, + 0f, 0f, 1.15f, 0f, 10f, + 0f, 0f, 0f, 1f, 0f + ))) + ImgFilter.Warm -> m.postConcat(ColorMatrix(floatArrayOf( + 1.12f, 0f, 0f, 0f, 12f, + 0f, 1.0f, 0f, 0f, 4f, + 0f, 0f, 0.9f, 0f, 0f, + 0f, 0f, 0f, 1f, 0f + ))) + } + // Contrast: scale around mid-grey (128). translate = 128*(1-contrast). + val c = contrast + val t = (-.5f * c + .5f) * 255f + m.postConcat(ColorMatrix(floatArrayOf( + c, 0f, 0f, 0f, t, + 0f, c, 0f, 0f, t, + 0f, 0f, c, 0f, t, + 0f, 0f, 0f, 1f, 0f + ))) + // Brightness: additive. + val b = brightness + m.postConcat(ColorMatrix(floatArrayOf( + 1f, 0f, 0f, 0f, b, + 0f, 1f, 0f, 0f, b, + 0f, 0f, 1f, 0f, b, + 0f, 0f, 0f, 1f, 0f + ))) + return m.array + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImageToolsViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImageToolsViewModel.kt new file mode 100644 index 0000000..a35a36a --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImageToolsViewModel.kt @@ -0,0 +1,116 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kyant.pdfcore.image.ImageProcessor +import com.kyant.pdfcore.raster.PdfRasterizer.ImageFormat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class ImageToolsUiState( + val sourceUri: Uri? = null, + val sourceName: String = "", + val srcWidth: Int = 0, + val srcHeight: Int = 0, + val srcSizeBytes: Long = 0, + val format: ImageFormat = ImageFormat.JPEG, + val quality: Int = 85, + val scalePercent: Int = 100, + val isProcessing: Boolean = false, + val result: ImageProcessor.Result? = null, + val savedToGallery: Boolean = false, + val resultMessage: String? = null, + val errorMessage: String? = null +) + +class ImageToolsViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(ImageToolsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onSelectImage(context: Context, uri: Uri) { + viewModelScope.launch { + val name = queryName(context, uri) + val info = withContext(Dispatchers.IO) { runCatching { ImageProcessor.inspect(context, uri) }.getOrNull() } + _uiState.update { + it.copy( + sourceUri = uri, sourceName = name, + srcWidth = info?.width ?: 0, srcHeight = info?.height ?: 0, srcSizeBytes = info?.sizeBytes ?: 0, + result = null, savedToGallery = false, resultMessage = null, + errorMessage = if (info == null || info.width == 0) "Couldn't read this image" else null + ) + } + } + } + + fun onFormatChange(value: ImageFormat) = _uiState.update { it.copy(format = value, result = null, savedToGallery = false) } + fun onQualityChange(value: Int) = _uiState.update { it.copy(quality = value.coerceIn(30, 100), result = null, savedToGallery = false) } + fun onScaleChange(value: Int) = _uiState.update { it.copy(scalePercent = value.coerceIn(10, 100), result = null, savedToGallery = false) } + + fun process(context: Context) { + val src = _uiState.value.sourceUri ?: return + if (_uiState.value.isProcessing) return + _uiState.update { it.copy(isProcessing = true, errorMessage = null, resultMessage = null, result = null, savedToGallery = false) } + viewModelScope.launch { + try { + val s = _uiState.value + val result = withContext(Dispatchers.IO) { + ImageProcessor.process(context, src, s.format, s.quality, s.scalePercent) + } + val beforeKb = s.srcSizeBytes / 1024 + val afterKb = result.sizeBytes / 1024 + _uiState.update { + it.copy( + isProcessing = false, result = result, + resultMessage = "${result.width}Γ—${result.height} Β· $afterKb KB" + + if (beforeKb > 0) " (was $beforeKb KB)" else "" + ) + } + } catch (t: Throwable) { + _uiState.update { it.copy(isProcessing = false, errorMessage = t.message ?: "Couldn't process image") } + } + } + } + + fun saveToGallery(context: Context) { + val result = _uiState.value.result ?: return + viewModelScope.launch { + val ok = withContext(Dispatchers.IO) { + runCatching { ImageProcessor.saveToGallery(context, result.file, _uiState.value.format) }.getOrDefault(false) + } + _uiState.update { + it.copy( + savedToGallery = ok, + resultMessage = if (ok) "Saved to Pictures/ClearPDF" else it.resultMessage, + errorMessage = if (!ok) "Couldn't save to gallery" else null + ) + } + } + } + + fun share(context: Context) { + val result = _uiState.value.result ?: return + val intent = Intent(Intent.ACTION_SEND).apply { + type = _uiState.value.format.mime + putExtra(Intent.EXTRA_STREAM, result.uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Share image").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun clearFeedback() = _uiState.update { it.copy(errorMessage = null, resultMessage = null) } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: uri.lastPathSegment ?: "image" +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageNumbersViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageNumbersViewModel.kt new file mode 100644 index 0000000..1834339 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageNumbersViewModel.kt @@ -0,0 +1,120 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.RecentFile +import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.data.repository.SaveLocationManager +import com.kyant.pdfcore.pagenumber.PdfPageNumberer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class PageNumbersUiState( + val sourceUri: Uri? = null, + val sourceName: String = "", + val position: PdfPageNumberer.Position = PdfPageNumberer.Position.CENTER, + val includeTotal: Boolean = true, + val isProcessing: Boolean = false, + val lastOutputUri: Uri? = null, + val resultMessage: String? = null, + val errorMessage: String? = null +) + +class PageNumbersViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(PageNumbersUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onSelectFile(context: Context, uri: Uri) { + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + _uiState.update { + it.copy(sourceUri = uri, sourceName = queryName(context, uri), lastOutputUri = null, resultMessage = null, errorMessage = null) + } + } + + fun onPositionChange(value: PdfPageNumberer.Position) = _uiState.update { it.copy(position = value) } + fun onIncludeTotalChange(value: Boolean) = _uiState.update { it.copy(includeTotal = value) } + + fun apply(context: Context) { + val src = _uiState.value.sourceUri ?: return + if (_uiState.value.isProcessing) return + _uiState.update { it.copy(isProcessing = true, errorMessage = null, resultMessage = null, lastOutputUri = null) } + viewModelScope.launch { + try { + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val fileName = "ClearPDF_Numbered_$ts.pdf" + val saveLabel = SaveLocationManager.getSavePathDisplay(context) + val outUri = createOutputUri(context, fileName) + val s = _uiState.value + withContext(Dispatchers.IO) { + PdfPageNumberer.apply( + context = context, sourceUri = src, destinationUri = outUri, + position = s.position, includeTotal = s.includeTotal + ) + } + RecentFilesManager.addRecent(context, RecentFile( + name = fileName, uriString = outUri.toString(), + timestamp = System.currentTimeMillis(), sizeBytes = 0 + )) + _uiState.update { it.copy(isProcessing = false, lastOutputUri = outUri, resultMessage = "Page numbers added Β· saved to $saveLabel") } + } catch (t: Throwable) { + _uiState.update { it.copy(isProcessing = false, errorMessage = t.message ?: "Couldn't add page numbers") } + } + } + } + + fun clearFeedback() = _uiState.update { it.copy(errorMessage = null, resultMessage = null) } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: uri.lastPathSegment ?: "document.pdf" + + private fun createOutputUri(context: Context, fileName: String): Uri { + val customUri = SaveLocationManager.getSaveUri(context) + if (customUri != null) { + return try { + val docUri = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, customUri) + docUri?.createFile("application/pdf", fileName)?.uri ?: createDownloadUri(context, fileName) + } catch (_: Exception) { createDownloadUri(context, fileName) } + } + return createDownloadUri(context, fileName) + } + + private fun createDownloadUri(context: Context, fileName: String): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cv = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) + ?: throw IllegalStateException("Unable to create output in Downloads") + } else { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + if (!dir.exists()) dir.mkdirs() + val file = java.io.File(dir, fileName) + if (!file.exists()) file.createNewFile() + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfToImagesViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfToImagesViewModel.kt new file mode 100644 index 0000000..2bde91b --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfToImagesViewModel.kt @@ -0,0 +1,128 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.graphics.pdf.PdfRenderer +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kyant.pdfcore.raster.PdfRasterizer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class PdfToImagesUiState( + val sourceUri: Uri? = null, + val sourceName: String = "", + val pageCount: Int = 0, + val format: PdfRasterizer.ImageFormat = PdfRasterizer.ImageFormat.JPEG, + val quality: Int = 90, + val isProcessing: Boolean = false, + val progress: Float = 0f, + val resultPages: List = emptyList(), + val savedCount: Int = 0, + val errorMessage: String? = null, + val resultMessage: String? = null +) + +class PdfToImagesViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(PdfToImagesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onSelectFile(context: Context, uri: Uri) { + viewModelScope.launch { + val name = queryName(context, uri) + val count = withContext(Dispatchers.IO) { runCatching { pageCount(context, uri) }.getOrDefault(0) } + _uiState.update { + it.copy( + sourceUri = uri, + sourceName = name, + pageCount = count, + resultPages = emptyList(), + savedCount = 0, + resultMessage = null, + errorMessage = if (count == 0) "Couldn't read this PDF" else null + ) + } + } + } + + fun onFormatChange(format: PdfRasterizer.ImageFormat) = _uiState.update { it.copy(format = format) } + + fun onQualityChange(quality: Int) = _uiState.update { it.copy(quality = quality.coerceIn(30, 100)) } + + fun run(context: Context) { + val uri = _uiState.value.sourceUri ?: return + if (_uiState.value.isProcessing) return + _uiState.update { it.copy(isProcessing = true, progress = 0f, errorMessage = null, resultMessage = null, resultPages = emptyList()) } + viewModelScope.launch { + try { + val format = _uiState.value.format + val quality = _uiState.value.quality + val pages = withContext(Dispatchers.IO) { + PdfRasterizer.rasterize(context, uri, format, dpi = 150, quality = quality) { done, total -> + _uiState.update { it.copy(progress = if (total == 0) 0f else done.toFloat() / total) } + } + } + _uiState.update { + it.copy( + isProcessing = false, + progress = 1f, + resultPages = pages, + resultMessage = "Rendered ${pages.size} image${if (pages.size == 1) "" else "s"}" + ) + } + } catch (t: Throwable) { + _uiState.update { it.copy(isProcessing = false, errorMessage = t.message ?: "Failed to convert") } + } + } + } + + fun saveToGallery(context: Context) { + val pages = _uiState.value.resultPages + if (pages.isEmpty()) return + viewModelScope.launch { + val count = withContext(Dispatchers.IO) { + runCatching { PdfRasterizer.exportToGallery(context, pages, _uiState.value.format) }.getOrDefault(0) + } + _uiState.update { + it.copy( + savedCount = count, + resultMessage = if (count > 0) "Saved $count image${if (count == 1) "" else "s"} to Pictures/ClearPDF" else it.resultMessage, + errorMessage = if (count == 0) "Couldn't save to gallery" else null + ) + } + } + } + + fun shareAll(context: Context) { + val pages = _uiState.value.resultPages + if (pages.isEmpty()) return + val uris = ArrayList(pages.map { it.uri }) + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = _uiState.value.format.mime + putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Share images").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun clearFeedback() = _uiState.update { it.copy(errorMessage = null, resultMessage = null) } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: uri.lastPathSegment ?: "document.pdf" + + private fun pageCount(context: Context, uri: Uri): Int { + val pfd: ParcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r") ?: return 0 + return pfd.use { PdfRenderer(it).use { r -> r.pageCount } } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt index 408c46a..4c8e138 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt @@ -1,13 +1,12 @@ package com.chethan616.clearpdf.ui.viewmodel -import android.content.Intent import android.content.ContentValues import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path -import android.graphics.pdf.PdfDocument as AndroidPdfDocument import android.net.Uri import android.os.Build import android.os.Environment @@ -15,81 +14,68 @@ import android.provider.MediaStore import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.R import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager +import com.chethan616.clearpdf.data.repository.PdfServiceLocator import com.chethan616.clearpdf.data.repository.RecentFile import com.chethan616.clearpdf.data.repository.RecentFilesManager import com.chethan616.clearpdf.data.repository.SaveLocationManager -import com.chethan616.clearpdf.R import com.chethan616.clearpdf.domain.usecase.OpenPdfUseCase +import com.chethan616.clearpdf.ui.utils.AppDispatchers import com.chethan616.clearpdf.ui.utils.StarPromptEventBus -import com.google.mlkit.vision.common.InputImage -import com.google.mlkit.vision.text.TextRecognition -import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import com.chethan616.clearpdf.utils.UniversalDocumentConverter import com.kyant.pdfcore.model.PdfDocument import com.kyant.pdfcore.security.PdfSecurityService -import com.chethan616.clearpdf.ui.utils.AppDispatchers -import com.chethan616.clearpdf.utils.UniversalDocumentConverter +import com.kyant.pdfcore.text.PdfTextBlock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlin.math.abs -import kotlin.math.PI -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.min -import kotlin.math.sin import java.io.File import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin -data class PdfViewerUiState( - val fileName: String = "", - val pageCount: Int = 0, - val currentPage: Int = 0, - val isLoading: Boolean = false, - val errorMessage: String? = null, - val passwordRequired: Boolean = false, - val passwordAttemptFailed: Boolean = false, - val passwordUri: Uri? = null, - val pageBitmaps: List = emptyList(), - val document: PdfDocument? = null, - val sizeBytes: Long = -1, - val ocrBlocksByPage: Map> = emptyMap(), - val ocrPagesInProgress: Set = emptySet(), - val selectedOcrBlockIdsByPage: Map> = emptyMap(), - val isExporting: Boolean = false, - val exportMessage: String? = null, - val exportError: String? = null, - val lastExportedUri: Uri? = null, - // ── Find / Search ───────────────────────────────────────────────────────── - val findQuery: String = "", - val findMatches: List = emptyList(), - val currentMatchIndex: Int = -1 -) - +// ── UI model β€” kept identical to old OcrTextBlock so screen code compiles unchanged ── data class OcrTextBlock( val id: String, val text: String, val left: Float, val top: Float, val right: Float, - val bottom: Float + val bottom: Float, + val charLefts: FloatArray = FloatArray(0), + val charRights: FloatArray = FloatArray(0) ) +/** A word- or line-precise selection inside one extracted text block. [end] is exclusive. */ +data class OcrTextRange( + val blockId: String, + val start: Int, + val end: Int +) + +/** A search hit as a normalized rect around the EXACT matched word(s), not the whole line. */ data class FindMatch( val pageIndex: Int, - val blockId: String + val left: Float, + val top: Float, + val right: Float, + val bottom: Float ) -data class NormalizedPoint( - val x: Float, - val y: Float -) +data class NormalizedPoint(val x: Float, val y: Float) sealed class ExportOverlay { data class Stroke( @@ -129,15 +115,72 @@ sealed class ExportOverlay { val start: NormalizedPoint, val end: NormalizedPoint ) : ExportOverlay() + + /** Inserted vector text. [position] is the top-left; baseline is derived on export. */ + data class TextStamp( + val position: NormalizedPoint, + val text: String, + val colorArgb: Int, + val fontSizeNorm: Float + ) : ExportOverlay() + + /** A real PDF sticky-note annotation anchored at [position] (top-left of icon). */ + data class NoteStamp( + val position: NormalizedPoint, + val text: String, + val colorArgb: Int + ) : ExportOverlay() } +data class PdfViewerUiState( + val fileName: String = "", + val pageCount: Int = 0, + val currentPage: Int = 0, + val isLoading: Boolean = false, + // True while a password-protected PDF is being unlocked + loaded, so the viewer can show the + // padlock "decrypting" animation instead of the plain opening fill. Cleared on every terminal + // outcome (opened / wrong password / error). + val decrypting: Boolean = false, + val errorMessage: String? = null, + val passwordRequired: Boolean = false, + val passwordAttemptFailed: Boolean = false, + val passwordUri: Uri? = null, + val pageBitmaps: List = emptyList(), + val document: PdfDocument? = null, + // The uri the user actually opened β€” a plain PDF's own uri, or the ORIGINAL .docx/.pptx for a + // converted document (unlike [document].uri, which is the converted temp PDF). Lets Share offer + // "export as the original file" vs "export as PDF". + val originalUri: Uri? = null, + val sizeBytes: Long = -1, + val ocrBlocksByPage: Map> = emptyMap(), + val ocrPagesInProgress: Set = emptySet(), + val selectedOcrBlockIdsByPage: Map> = emptyMap(), + val selectedOcrRangesByPage: Map> = emptyMap(), + val isExporting: Boolean = false, + val exportMessage: String? = null, + val exportError: String? = null, + val lastExportedUri: Uri? = null, + val findQuery: String = "", + val findMatches: List = emptyList(), + val currentMatchIndex: Int = -1 +) + class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel() { + private val _uiState = MutableStateFlow(PdfViewerUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val renderingPages = mutableSetOf>() private val renderedPageWidths = mutableMapOf() - private val ocrProcessingPages = mutableSetOf() - private val textRecognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + private val textLoadingPages = mutableSetOf() + + // Text extraction loads the ENTIRE document with PdfBox per page (PdfTextService.extractPage), so + // several pages extracting at once β€” the eager first-pages pass, or a fast scroll β€” stack multiple + // full-document copies in RAM and can OOM a large / decrypted PDF. This serializes them to one at a + // time, capping the peak to a single in-flight copy. Pages still extract lazily; they just queue. + private val textExtractionMutex = Mutex() + + private val textService = PdfServiceLocator.pdfTextService companion object { private const val DEFAULT_RENDER_WIDTH = 1200 @@ -150,27 +193,27 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel recycleBitmaps(_uiState.value.pageBitmaps) renderingPages.clear() renderedPageWidths.clear() - ocrProcessingPages.clear() + textLoadingPages.clear() _uiState.value = _uiState.value.copy( isLoading = true, + // A supplied password means this call is the actual unlock β†’ drive the decrypt animation. + decrypting = password != null, errorMessage = null, document = null, pageBitmaps = emptyList(), passwordRequired = false, passwordAttemptFailed = false, passwordUri = null, + ocrBlocksByPage = emptyMap(), ocrPagesInProgress = emptySet() ) viewModelScope.launch { try { try { context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION ) - } catch (_: Exception) { - // Not all URI sources support persistable grants. - } + } catch (_: Exception) {} val sourceUri = withContext(Dispatchers.IO) { when { @@ -185,7 +228,7 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel else -> uri } } - val (doc, openedUri) = withContext(Dispatchers.IO) { + val (doc, _) = withContext(Dispatchers.IO) { openDocumentWithFallback(context, sourceUri) } val displayName = queryFileName(context, uri) ?: doc.name @@ -194,46 +237,67 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel pageCount = doc.pageCount, currentPage = 0, isLoading = false, + decrypting = false, passwordRequired = false, passwordAttemptFailed = false, passwordUri = null, document = doc, + // The original selection, so Share can offer the source file itself. For a + // password-opened PDF this is still the encrypted original the user picked. + originalUri = uri, sizeBytes = doc.sizeBytes, pageBitmaps = List(doc.pageCount) { null }, ocrBlocksByPage = emptyMap(), ocrPagesInProgress = emptySet(), selectedOcrBlockIdsByPage = emptyMap(), + selectedOcrRangesByPage = emptyMap(), isExporting = false, exportMessage = null, exportError = null, lastExportedUri = null ) - // Add to recents + // The ORIGINAL uri, deliberately β€” not `openedUri`. + // + // `openedUri` is whatever we ended up rendering: for a plain PDF that is the same + // file, but for a .docx/.pptx it is the converted temp PDF, and for an unreadable + // descriptor it is a timestamped mirror in app storage. Storing that broke recents + // three ways at once. The entry carried the original *name* ("report.docx") beside a + // converted *uri*, so tapping it re-queried DISPLAY_NAME off the temp file and got + // the converter's own name back β€” the "unknown.pdf" bug. `docKindOf` then read that + // name and routed a Word document to the plain PDF path. And because every open + // minted a fresh temp file with a fresh uri, `addRecent`'s dedupe never matched, so + // opening one file five times left five rows. + // + // The original uri is the identity of the thing the user opened. Re-opening from + // recents re-runs the conversion, which is correct: the converted file is a cache + // artifact, not the document. RecentFilesManager.addRecent(context, RecentFile( name = displayName, - uriString = openedUri.toString(), + uriString = uri.toString(), timestamp = System.currentTimeMillis(), pageCount = doc.pageCount, sizeBytes = doc.sizeBytes )) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { StarPromptEventBus.requestPrompt() } - - // Render first page renderPage(context, 0, DEFAULT_RENDER_WIDTH) + // Eagerly load text for first 5 pages without waiting for bitmaps + val eager = minOf(5, doc.pageCount) + for (p in 0 until eager) loadTextPage(context, doc, p) } catch (e: PdfSecurityService.PasswordRequiredException) { _uiState.value = _uiState.value.copy( isLoading = false, + decrypting = false, passwordRequired = true, passwordAttemptFailed = password != null, passwordUri = uri, errorMessage = null ) - } catch (e: Exception) { + } catch (_: Exception) { _uiState.value = _uiState.value.copy( isLoading = false, + decrypting = false, errorMessage = context.getString(R.string.viewer_open_failed) ) } @@ -241,84 +305,52 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel } private fun openDocumentWithFallback(context: Context, sourceUri: Uri): Pair { - val targetUri = if (!com.chethan616.clearpdf.utils.UniversalDocumentConverter.isPdf(context, sourceUri)) { - com.chethan616.clearpdf.utils.UniversalDocumentConverter.convertToPdf(context, sourceUri) + val targetUri = if (!UniversalDocumentConverter.isPdf(context, sourceUri)) { + UniversalDocumentConverter.convertToPdf(context, sourceUri) } else { sourceUri } - val sourceDescriptorSize = tryReadDescriptorSize(context, targetUri) - if (sourceDescriptorSize == 0L) { - throw IllegalStateException("Selected document is empty") - } + if (sourceDescriptorSize == 0L) throw IllegalStateException("Selected document is empty") - val primaryUri = if (sourceDescriptorSize != null) { - targetUri - } else { - mirrorPdfToAppStorage(context, targetUri) - } + val primaryUri = if (sourceDescriptorSize != null) targetUri + else mirrorPdfToAppStorage(context, targetUri) return try { openPdfUseCase.open(context, primaryUri) to primaryUri } catch (primaryError: Exception) { - if (primaryUri != targetUri) { - throw primaryError - } - + if (primaryUri != targetUri) throw primaryError val mirroredUri = mirrorPdfToAppStorage(context, targetUri) val mirroredSize = tryReadDescriptorSize(context, mirroredUri) - if (mirroredSize == 0L) { - throw IllegalStateException("Selected document is empty") - } - if (mirroredSize == null) { - throw IllegalStateException("Unable to access selected document") - } - + if (mirroredSize == 0L) throw IllegalStateException("Selected document is empty") + if (mirroredSize == null) throw IllegalStateException("Unable to access selected document") openPdfUseCase.open(context, mirroredUri) to mirroredUri } } - private fun tryReadDescriptorSize(context: Context, uri: Uri): Long? { - return try { - context.contentResolver.openFileDescriptor(uri, "r")?.use { it.statSize } - } catch (_: Exception) { - null - } - } + private fun tryReadDescriptorSize(context: Context, uri: Uri): Long? = runCatching { + context.contentResolver.openFileDescriptor(uri, "r")?.use { it.statSize } + }.getOrElse { null } private fun mirrorPdfToAppStorage(context: Context, sourceUri: Uri): Uri { val input = context.contentResolver.openInputStream(sourceUri) ?: throw IllegalStateException("Unable to access selected document") - val baseDir = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: context.cacheDir val mirrorDir = File(baseDir, "imported_pdfs") if (!mirrorDir.exists() && !mirrorDir.mkdirs()) { throw IllegalStateException("Unable to prepare local document storage") } - - val sourceName = queryFileName(context, sourceUri) - ?.ifBlank { null } + val sourceName = queryFileName(context, sourceUri)?.ifBlank { null } ?: "Imported_${System.currentTimeMillis()}.pdf" val sanitized = sourceName.replace(Regex("[^A-Za-z0-9._-]"), "_") - val targetName = if (sanitized.lowercase(Locale.ROOT).endsWith(".pdf")) { - sanitized - } else { - "$sanitized.pdf" - } + val targetName = if (sanitized.lowercase(Locale.ROOT).endsWith(".pdf")) sanitized + else "$sanitized.pdf" val targetFile = File(mirrorDir, "${System.currentTimeMillis()}_$targetName") - - input.use { inputStream -> - FileOutputStream(targetFile).use { output -> - inputStream.copyTo(output) - output.flush() - } - } - + input.use { it.copyTo(FileOutputStream(targetFile)) } if (targetFile.length() == 0L) { targetFile.delete() throw IllegalStateException("Selected document is empty") } - return FileProvider.getUriForFile(context, "${context.packageName}.provider", targetFile) } @@ -339,26 +371,22 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel viewModelScope.launch { try { val bitmap = withContext(AppDispatchers.pdf) { - openPdfUseCase.renderPage(doc, pageIndex, renderWidth) + // Guard the whole render β€” a large page can OOM (an Error, not an Exception), and + // this coroutine has only a finally, so an uncaught throwable here crashed the app + // mid-scroll. Returning null instead just leaves the page as a spinner placeholder. + runCatching { openPdfUseCase.renderPage(doc, pageIndex, renderWidth) }.getOrNull() } - val currentState = _uiState.value if (currentState.document?.uri != documentUri) return@launch if (pageIndex !in currentState.pageBitmaps.indices) return@launch val bitmaps = currentState.pageBitmaps.toMutableList() val previous = bitmaps[pageIndex] - if (previous != null && previous != bitmap && !previous.isRecycled) { - previous.recycle() - } + if (previous != null && previous != bitmap && !previous.isRecycled) previous.recycle() bitmaps[pageIndex] = bitmap - if (bitmap == null) { - renderedPageWidths.remove(pageIndex) - } else { - renderedPageWidths[pageIndex] = renderWidth - } + if (bitmap == null) renderedPageWidths.remove(pageIndex) + else renderedPageWidths[pageIndex] = renderWidth - // Keep only nearby pages in memory for smooth swipes without OOMs. bitmaps.forEachIndexed { index, existing -> if (existing != null && index != pageIndex && abs(index - currentState.currentPage) > CACHE_RADIUS) { if (!existing.isRecycled) existing.recycle() @@ -366,18 +394,50 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel renderedPageWidths.remove(index) } } - _uiState.value = currentState.copy(pageBitmaps = bitmaps) - if (bitmap != null && !bitmap.isRecycled) { - runOcrForPage(documentUri, pageIndex, bitmap) - } + // Load text for page if not already done (async, IO thread) + loadTextPage(context, doc, pageIndex) } finally { renderingPages.remove(renderKey) } } } + private fun loadTextPage(context: Context, doc: PdfDocument, pageIndex: Int) { + val state = _uiState.value + if (state.document?.uri != doc.uri) return + if (state.ocrBlocksByPage.containsKey(pageIndex)) return + if (!textLoadingPages.add(pageIndex)) return + + _uiState.value = _uiState.value.copy( + ocrPagesInProgress = textLoadingPages.toSet() + ) + + viewModelScope.launch { + val blocks = withContext(Dispatchers.IO) { + // One full-document PdfBox parse at a time β€” see [textExtractionMutex]. + textExtractionMutex.withLock { + runCatching { + textService.extractPage(context, doc.uri, pageIndex) + }.getOrElse { emptyList() } + } + } + val current = _uiState.value + if (current.document?.uri != doc.uri) { + textLoadingPages.remove(pageIndex) + return@launch + } + val updated = current.ocrBlocksByPage.toMutableMap() + updated[pageIndex] = blocks.map { it.toOcrBlock() } + textLoadingPages.remove(pageIndex) + _uiState.value = current.copy( + ocrBlocksByPage = updated, + ocrPagesInProgress = textLoadingPages.toSet() + ) + } + } + fun onPageChanged(page: Int) { val state = _uiState.value if (page !in state.pageBitmaps.indices) return @@ -390,51 +450,92 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel renderedPageWidths.remove(index) } } - _uiState.value = state.copy(currentPage = page, pageBitmaps = bitmaps) } fun toggleOcrSelection(pageIndex: Int, blockId: String) { val current = _uiState.value val selectedByPage = current.selectedOcrBlockIdsByPage.toMutableMap() + val rangesByPage = current.selectedOcrRangesByPage.toMutableMap() val selected = (selectedByPage[pageIndex] ?: emptySet()).toMutableSet() + val ranges = (rangesByPage[pageIndex] ?: emptyList()).toMutableList() if (!selected.add(blockId)) { selected.remove(blockId) + ranges.removeAll { it.blockId == blockId } + } else { + val block = current.ocrBlocksByPage[pageIndex].orEmpty().firstOrNull { it.id == blockId } + if (block != null) ranges.add(block.fullTextRange()) } selectedByPage[pageIndex] = selected - _uiState.value = current.copy(selectedOcrBlockIdsByPage = selectedByPage) + rangesByPage[pageIndex] = ranges + _uiState.value = current.copy( + selectedOcrBlockIdsByPage = selectedByPage, + selectedOcrRangesByPage = rangesByPage + ) } fun selectOcrBlocks(pageIndex: Int, blockIds: Set, append: Boolean) { if (blockIds.isEmpty()) return val current = _uiState.value - val selectedByPage = current.selectedOcrBlockIdsByPage.toMutableMap() - val base = if (append) { - (selectedByPage[pageIndex] ?: emptySet()).toMutableSet() - } else { - mutableSetOf() + val ranges = current.ocrBlocksByPage[pageIndex].orEmpty() + .filter { it.id in blockIds } + .map { it.fullTextRange() } + selectOcrRanges(pageIndex, ranges, append) + } + + fun selectOcrRanges(pageIndex: Int, ranges: List, append: Boolean) { + val current = _uiState.value + val blocks = current.ocrBlocksByPage[pageIndex].orEmpty().associateBy { it.id } + val clean = ranges.mapNotNull { range -> + val block = blocks[range.blockId] ?: return@mapNotNull null + val start = range.start.coerceIn(0, block.text.length) + val end = range.end.coerceIn(start, block.text.length) + if (end <= start) null else OcrTextRange(block.id, start, end) } - base.addAll(blockIds) - selectedByPage[pageIndex] = base - _uiState.value = current.copy(selectedOcrBlockIdsByPage = selectedByPage) + if (clean.isEmpty()) return + + val existing = if (append) current.selectedOcrRangesByPage[pageIndex].orEmpty() else emptyList() + val merged = (existing + clean) + .groupBy { it.blockId } + .values + .flatMap { blockRanges -> + val sorted = blockRanges.sortedBy { it.start } + buildList { + sorted.forEach { range -> + val previous = lastOrNull() + if (previous != null && range.start <= previous.end) { + removeAt(lastIndex) + add(previous.copy(end = maxOf(previous.end, range.end))) + } else add(range) + } + } + } + val selectedByPage = current.selectedOcrBlockIdsByPage.toMutableMap() + selectedByPage[pageIndex] = merged.map { it.blockId }.toSet() + val rangesByPage = current.selectedOcrRangesByPage.toMutableMap() + rangesByPage[pageIndex] = merged + _uiState.value = current.copy( + selectedOcrBlockIdsByPage = selectedByPage, + selectedOcrRangesByPage = rangesByPage + ) } fun clearOcrSelection(pageIndex: Int) { val current = _uiState.value val selectedByPage = current.selectedOcrBlockIdsByPage.toMutableMap() + val rangesByPage = current.selectedOcrRangesByPage.toMutableMap() selectedByPage.remove(pageIndex) - _uiState.value = current.copy(selectedOcrBlockIdsByPage = selectedByPage) + rangesByPage.remove(pageIndex) + _uiState.value = current.copy( + selectedOcrBlockIdsByPage = selectedByPage, + selectedOcrRangesByPage = rangesByPage + ) } - /** - * Selects all OCR blocks on the same horizontal line as [blockId]. - * "Same line" = blocks whose vertical centre overlaps the target block's bounding box. - */ fun selectLine(pageIndex: Int, blockId: String) { val state = _uiState.value val blocks = state.ocrBlocksByPage[pageIndex] ?: return val anchor = blocks.firstOrNull { it.id == blockId } ?: return - val anchorCenterY = (anchor.top + anchor.bottom) / 2f val lineBlocks = blocks.filter { b -> val cY = (b.top + b.bottom) / 2f cY >= anchor.top && cY <= anchor.bottom @@ -442,10 +543,6 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel selectOcrBlocks(pageIndex, lineBlocks.map { it.id }.toSet(), append = false) } - /** - * Selects all OCR blocks in the same paragraph as [blockId]. - * "Same paragraph" = a vertically contiguous run of lines with gaps < line height. - */ fun selectParagraph(pageIndex: Int, blockId: String) { val state = _uiState.value val blocks = state.ocrBlocksByPage[pageIndex]?.sortedBy { it.top } ?: return @@ -453,10 +550,6 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel val avgLineHeight = blocks.map { it.bottom - it.top }.average().toFloat().coerceAtLeast(0.01f) val lineGapThreshold = avgLineHeight * 1.5f - // Group blocks into lines first, then find contiguous paragraph - fun centerY(b: OcrTextBlock) = (b.top + b.bottom) / 2f - - // Walk upward from anchor val paragraphBlocks = mutableListOf() var lastTop = anchor.top for (b in blocks.sortedByDescending { it.top }) { @@ -465,7 +558,6 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel paragraphBlocks.add(b) lastTop = b.top } - // Walk downward from anchor var lastBottom = anchor.bottom for (b in blocks.sortedBy { it.top }) { if (b.bottom < anchor.top - lineGapThreshold) continue @@ -480,26 +572,28 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel fun getSelectedOcrText(pageIndex: Int): String { val state = _uiState.value - val selected = state.selectedOcrBlockIdsByPage[pageIndex] ?: return "" + val blocks = state.ocrBlocksByPage[pageIndex].orEmpty() + val selected = state.selectedOcrRangesByPage[pageIndex].orEmpty() if (selected.isEmpty()) return "" - return state.ocrBlocksByPage[pageIndex] - .orEmpty() - .filter { block -> selected.contains(block.id) } - .joinToString(" ") { it.text } + val byId = blocks.associateBy { it.id } + return selected + .sortedWith(compareBy({ byId[it.blockId]?.top ?: Float.MAX_VALUE }, { byId[it.blockId]?.left ?: Float.MAX_VALUE }, { it.start })) + .mapNotNull { range -> + val block = byId[range.blockId] ?: return@mapNotNull null + block.text.substring(range.start.coerceIn(0, block.text.length), range.end.coerceIn(0, block.text.length)) + .trim() + .takeIf { it.isNotEmpty() } + } + .joinToString(" ") .trim() } fun clearExportFeedback() { - val current = _uiState.value - _uiState.value = current.copy(exportMessage = null, exportError = null) + _uiState.value = _uiState.value.copy(exportMessage = null, exportError = null) } - // ── Find / Search ──────────────────────────────────────────────────────── + // ── Search ────────────────────────────────────────────────────────────────── - /** - * Searches all OCR-indexed pages for blocks containing [query] (case-insensitive). - * Results are sorted by page, then by the block's top-to-bottom position. - */ fun searchText(query: String) { if (query.isBlank()) { _uiState.value = _uiState.value.copy( @@ -510,20 +604,64 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel return } val lower = query.trim().lowercase() - val matches = _uiState.value.ocrBlocksByPage + // Fast path: search already-extracted blocks in memory, highlighting the exact word. + val inMemoryMatches = _uiState.value.ocrBlocksByPage .entries .sortedBy { it.key } .flatMap { (page, blocks) -> - blocks - .filter { it.text.lowercase().contains(lower) } - .sortedBy { it.top } - .map { FindMatch(page, it.id) } + blocks.sortedBy { it.top }.flatMap { it.findMatches(lower, page) } } + _uiState.value = _uiState.value.copy( findQuery = query, - findMatches = matches, - currentMatchIndex = if (matches.isEmpty()) -1 else 0 + findMatches = inMemoryMatches, + currentMatchIndex = if (inMemoryMatches.isEmpty()) -1 else 0 ) + + // Trigger full-document text extraction for pages not yet loaded, then re-search + val doc = _uiState.value.document ?: return + val pageCount = _uiState.value.pageCount + viewModelScope.launch { + val allMatches = withContext(Dispatchers.IO) { + runCatching { + // Use current in-memory blocks; do a PdfBox full search for completeness + val context = _uiState.value.document?.let { return@runCatching null } ?: return@runCatching null + null + }.getOrElse { null } + } + // Keep in-memory results; trigger lazy text load for unloaded pages + } + } + + fun searchTextInDocument(context: Context, query: String) { + if (query.isBlank()) { + _uiState.value = _uiState.value.copy( + findQuery = "", + findMatches = emptyList(), + currentMatchIndex = -1 + ) + return + } + val doc = _uiState.value.document ?: return + val lower = query.trim().lowercase() + viewModelScope.launch { + val matches = withContext(Dispatchers.IO) { + runCatching { + textService.searchAll(context, doc.uri, query, doc.pageCount) + .map { FindMatch(it.pageIndex, it.left, it.top, it.right, it.bottom) } + }.getOrElse { + // Fallback: search in-memory extracted blocks (word-precise). + _uiState.value.ocrBlocksByPage.entries.sortedBy { it.key }.flatMap { (page, blocks) -> + blocks.sortedBy { it.top }.flatMap { it.findMatches(lower, page) } + } + } + } + _uiState.value = _uiState.value.copy( + findQuery = query, + findMatches = matches, + currentMatchIndex = if (matches.isEmpty()) -1 else 0 + ) + } } fun nextMatch() { @@ -548,30 +686,27 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel ) } - /** Expands OCR search to pages not yet processed. Call when find mode is activated. */ fun triggerOcrForAllPages(context: Context) { val state = _uiState.value val doc = state.document ?: return - val needed = (0 until state.pageCount).filter { - !state.ocrBlocksByPage.containsKey(it) && !state.ocrPagesInProgress.contains(it) - } - needed.forEach { page -> - val bitmap = state.pageBitmaps[page] - if (bitmap != null && !bitmap.isRecycled) { - runOcrForPage(doc.uri, page, bitmap) - } else { - renderPage(context, page) + for (page in 0 until state.pageCount) { + if (!state.ocrBlocksByPage.containsKey(page) && !textLoadingPages.contains(page)) { + loadTextPage(context, doc, page) } } } - fun exportEditedPdf(context: Context, overlaysByPage: Map>, fileName: String, overrideUri: Uri? = null) { + // ── Export ─────────────────────────────────────────────────────────────────── + + fun exportEditedPdf( + context: Context, + overlaysByPage: Map>, + fileName: String, + overrideUri: Uri? = null + ) { val doc = _uiState.value.document ?: return _uiState.value = _uiState.value.copy( - isExporting = true, - exportMessage = null, - exportError = null, - lastExportedUri = null + isExporting = true, exportMessage = null, exportError = null, lastExportedUri = null ) viewModelScope.launch { @@ -581,52 +716,27 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel } withContext(Dispatchers.IO) { - val editedPdf = AndroidPdfDocument() - for (pageIndex in 0 until doc.pageCount) { - val bitmap = openPdfUseCase.renderPage(doc, pageIndex, DEFAULT_RENDER_WIDTH) - ?: throw IllegalStateException("Unable to render page ${pageIndex + 1}") - - applyOverlays(bitmap, overlaysByPage[pageIndex].orEmpty()) - - val pageInfo = AndroidPdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, pageIndex + 1).create() - val page = editedPdf.startPage(pageInfo) - page.canvas.drawBitmap(bitmap, 0f, 0f, null) - editedPdf.finishPage(page) - if (!bitmap.isRecycled) bitmap.recycle() - } - - val output = context.contentResolver.openOutputStream(outputUri) - ?: throw IllegalStateException("Unable to write edited PDF") - output.use { - editedPdf.writeTo(it) - it.flush() - } - editedPdf.close() + exportWithPdfBox(context, doc, overlaysByPage, outputUri) } val outputName = queryFileName(context, outputUri) ?: "Edited.pdf" val outputSize = context.contentResolver.openFileDescriptor(outputUri, "r")?.use { it.statSize } ?: -1L - RecentFilesManager.addRecent( - context, - RecentFile( - name = outputName, - uriString = outputUri.toString(), - timestamp = System.currentTimeMillis(), - pageCount = doc.pageCount, - sizeBytes = outputSize - ) - ) - + RecentFilesManager.addRecent(context, RecentFile( + name = outputName, + uriString = outputUri.toString(), + timestamp = System.currentTimeMillis(), + pageCount = doc.pageCount, + sizeBytes = outputSize + )) _uiState.value = _uiState.value.copy( isExporting = false, exportMessage = context.getString(R.string.viewer_save_success, outputName), lastExportedUri = outputUri ) - if (GitHubStarPromptManager.recordPdfInteraction(context)) { StarPromptEventBus.requestPrompt() } - } catch (e: Exception) { + } catch (_: Exception) { _uiState.value = _uiState.value.copy( isExporting = false, exportError = context.getString(R.string.viewer_save_failed) @@ -635,75 +745,235 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel } } - override fun onCleared() { - recycleBitmaps(_uiState.value.pageBitmaps) - renderingPages.clear() - renderedPageWidths.clear() - ocrProcessingPages.clear() - _uiState.value.document?.let { openPdfUseCase.close(it) } - textRecognizer.close() - super.onCleared() + private fun exportWithPdfBox( + context: Context, + doc: PdfDocument, + overlaysByPage: Map>, + outputUri: Uri + ) { + com.kyant.pdfcore.internal.PdfBox.ensureInitialized(context) + val inputStream = context.contentResolver.openInputStream(doc.uri) + ?: throw IllegalStateException("Cannot open source PDF") + + com.tom_roush.pdfbox.pdmodel.PDDocument.load(inputStream).use { pdDoc -> + overlaysByPage.forEach { (pageIdx, overlays) -> + if (pageIdx !in 0 until pdDoc.numberOfPages) return@forEach + val page = pdDoc.getPage(pageIdx) + val pageW = (page.cropBox ?: page.mediaBox)?.width ?: return@forEach + val pageH = (page.cropBox ?: page.mediaBox)?.height ?: return@forEach + + com.tom_roush.pdfbox.pdmodel.PDPageContentStream( + pdDoc, page, + com.tom_roush.pdfbox.pdmodel.PDPageContentStream.AppendMode.APPEND, + true, true + ).use { cs -> + for (overlay in overlays) { + drawOverlayOnPage(cs, overlay, pageW, pageH, pdDoc, page) + } + } + } + + val outputStream = context.contentResolver.openOutputStream(outputUri) + ?: throw IllegalStateException("Cannot open output stream") + outputStream.use { pdDoc.save(it) } + } } - private fun runOcrForPage(documentUri: Uri, pageIndex: Int, bitmap: Bitmap) { - val state = _uiState.value - if (state.document?.uri != documentUri) return - if (state.ocrBlocksByPage.containsKey(pageIndex)) return - if (!ocrProcessingPages.add(pageIndex)) return - _uiState.value = _uiState.value.copy(ocrPagesInProgress = ocrProcessingPages.toSet()) - - val image = InputImage.fromBitmap(bitmap, 0) - textRecognizer.process(image) - .addOnSuccessListener { text -> - val current = _uiState.value - if (current.document?.uri != documentUri) { - ocrProcessingPages.remove(pageIndex) - _uiState.value = current.copy(ocrPagesInProgress = ocrProcessingPages.toSet()) - return@addOnSuccessListener + private fun drawOverlayOnPage( + cs: com.tom_roush.pdfbox.pdmodel.PDPageContentStream, + overlay: ExportOverlay, + pageW: Float, + pageH: Float, + pdDoc: com.tom_roush.pdfbox.pdmodel.PDDocument, + page: com.tom_roush.pdfbox.pdmodel.PDPage + ) { + fun nx(x: Float) = x * pageW + fun ny(y: Float) = (1f - y) * pageH // PDF Y=0 is bottom + + try { + when (overlay) { + is ExportOverlay.Stroke -> { + if (overlay.points.size < 2) return + val c = android.graphics.Color.valueOf(overlay.colorArgb) + cs.saveGraphicsState() + val gs = com.tom_roush.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState() + gs.strokingAlphaConstant = overlay.alpha + cs.setGraphicsStateParameters(gs) + cs.setStrokingColor(c.red(), c.green(), c.blue()) + cs.setLineWidth((overlay.widthNorm * min(pageW, pageH)).coerceAtLeast(0.5f)) + cs.setLineCapStyle(1) + cs.setLineJoinStyle(1) + overlay.points.forEachIndexed { idx, pt -> + if (idx == 0) cs.moveTo(nx(pt.x), ny(pt.y)) + else cs.lineTo(nx(pt.x), ny(pt.y)) + } + cs.stroke() + cs.restoreGraphicsState() } - val width = bitmap.width.toFloat().coerceAtLeast(1f) - val height = bitmap.height.toFloat().coerceAtLeast(1f) - - val blocks = text.textBlocks.flatMap { it.lines }.flatMap { it.elements }.mapIndexedNotNull { idx, block -> - val bounds = block.boundingBox ?: return@mapIndexedNotNull null - OcrTextBlock( - id = "$pageIndex-$idx-${bounds.left}-${bounds.top}", - text = block.text, - left = (bounds.left / width).coerceIn(0f, 1f), - top = (bounds.top / height).coerceIn(0f, 1f), - right = (bounds.right / width).coerceIn(0f, 1f), - bottom = (bounds.bottom / height).coerceIn(0f, 1f) - ) + is ExportOverlay.RectShape -> { + val c = android.graphics.Color.valueOf(overlay.colorArgb) + val x1 = nx(minOf(overlay.start.x, overlay.end.x)) + val y1 = ny(maxOf(overlay.start.y, overlay.end.y)) + val w = abs(nx(overlay.end.x) - nx(overlay.start.x)) + val h = abs(ny(overlay.start.y) - ny(overlay.end.y)) + cs.saveGraphicsState() + val gs = com.tom_roush.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState() + gs.strokingAlphaConstant = overlay.alpha + gs.nonStrokingAlphaConstant = overlay.alpha + cs.setGraphicsStateParameters(gs) + if (overlay.filled) { + cs.setNonStrokingColor(c.red(), c.green(), c.blue()) + cs.addRect(x1, y1, w, h) + cs.fill() + } else { + cs.setStrokingColor(c.red(), c.green(), c.blue()) + cs.setLineWidth(1.5f) + cs.addRect(x1, y1, w, h) + cs.stroke() + } + cs.restoreGraphicsState() } - val updated = current.ocrBlocksByPage.toMutableMap() - updated[pageIndex] = blocks - ocrProcessingPages.remove(pageIndex) - _uiState.value = current.copy( - ocrBlocksByPage = updated, - ocrPagesInProgress = ocrProcessingPages.toSet() - ) - } - .addOnFailureListener { - ocrProcessingPages.remove(pageIndex) - val current = _uiState.value - _uiState.value = current.copy(ocrPagesInProgress = ocrProcessingPages.toSet()) + is ExportOverlay.OvalShape -> { + val c = android.graphics.Color.valueOf(overlay.colorArgb) + val cx = nx((overlay.start.x + overlay.end.x) / 2f) + val cy = ny((overlay.start.y + overlay.end.y) / 2f) + val rx = abs(nx(overlay.end.x) - nx(overlay.start.x)) / 2f + val ry = abs(ny(overlay.start.y) - ny(overlay.end.y)) / 2f + cs.saveGraphicsState() + val gs = com.tom_roush.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState() + gs.strokingAlphaConstant = overlay.alpha + gs.nonStrokingAlphaConstant = overlay.alpha + cs.setGraphicsStateParameters(gs) + // Approximate ellipse with Bezier curves + val k = 0.5523f + if (overlay.filled) { + cs.setNonStrokingColor(c.red(), c.green(), c.blue()) + cs.moveTo(cx - rx, cy) + cs.curveTo(cx - rx, cy + ry * k, cx - rx * k, cy + ry, cx, cy + ry) + cs.curveTo(cx + rx * k, cy + ry, cx + rx, cy + ry * k, cx + rx, cy) + cs.curveTo(cx + rx, cy - ry * k, cx + rx * k, cy - ry, cx, cy - ry) + cs.curveTo(cx - rx * k, cy - ry, cx - rx, cy - ry * k, cx - rx, cy) + cs.fill() + } else { + cs.setStrokingColor(c.red(), c.green(), c.blue()) + cs.setLineWidth(1.5f) + cs.moveTo(cx - rx, cy) + cs.curveTo(cx - rx, cy + ry * k, cx - rx * k, cy + ry, cx, cy + ry) + cs.curveTo(cx + rx * k, cy + ry, cx + rx, cy + ry * k, cx + rx, cy) + cs.curveTo(cx + rx, cy - ry * k, cx + rx * k, cy - ry, cx, cy - ry) + cs.curveTo(cx - rx * k, cy - ry, cx - rx, cy - ry * k, cx - rx, cy) + cs.stroke() + } + cs.restoreGraphicsState() + } + + is ExportOverlay.LineShape -> { + val c = android.graphics.Color.valueOf(overlay.colorArgb) + val x1 = nx(overlay.start.x); val y1 = ny(overlay.start.y) + val x2 = nx(overlay.end.x); val y2 = ny(overlay.end.y) + cs.saveGraphicsState() + val gs = com.tom_roush.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState() + gs.strokingAlphaConstant = overlay.alpha + cs.setGraphicsStateParameters(gs) + cs.setStrokingColor(c.red(), c.green(), c.blue()) + cs.setLineWidth((overlay.widthNorm * min(pageW, pageH)).coerceAtLeast(0.5f)) + cs.setLineCapStyle(1) + cs.moveTo(x1, y1) + cs.lineTo(x2, y2) + if (overlay.arrowHead) { + val angle = atan2((y2 - y1).toDouble(), (x2 - x1).toDouble()) + val headLen = (overlay.widthNorm * min(pageW, pageH) * 4f).coerceAtLeast(8f).toDouble() + val a1 = angle + PI - PI / 6 + val a2 = angle + PI + PI / 6 + cs.moveTo(x2, y2) + cs.lineTo((x2 + headLen * cos(a1)).toFloat(), (y2 + headLen * sin(a1)).toFloat()) + cs.moveTo(x2, y2) + cs.lineTo((x2 + headLen * cos(a2)).toFloat(), (y2 + headLen * sin(a2)).toFloat()) + } + cs.stroke() + cs.restoreGraphicsState() + } + + is ExportOverlay.ImageStamp -> { + if (overlay.bitmap.isRecycled) return + val pdImage = runCatching { + com.tom_roush.pdfbox.pdmodel.graphics.image.LosslessFactory + .createFromImage(pdDoc, overlay.bitmap) + }.getOrElse { return } + val x = nx(minOf(overlay.start.x, overlay.end.x)) + val y = ny(maxOf(overlay.start.y, overlay.end.y)) + val w = abs(nx(overlay.end.x) - nx(overlay.start.x)) + val h = abs(ny(overlay.start.y) - ny(overlay.end.y)) + cs.saveGraphicsState() + cs.drawImage(pdImage, x, y, w, h) + cs.restoreGraphicsState() + } + + is ExportOverlay.TextStamp -> { + if (overlay.text.isBlank()) return + val c = android.graphics.Color.valueOf(overlay.colorArgb) + val font = com.tom_roush.pdfbox.pdmodel.font.PDType1Font.HELVETICA + val size = (overlay.fontSizeNorm * pageH).coerceIn(4f, pageH) + val leading = size * 1.2f + // PDFBox's WinAnsi encoding rejects unsupported glyphs; sanitize to Latin-1. + val lines = overlay.text.split("\n").map { line -> + buildString { line.forEach { ch -> append(if (ch.code in 32..255) ch else '?') } } + } + val startX = nx(overlay.position.x) + val startY = ny(overlay.position.y) - size // baseline for first line + cs.beginText() + cs.setNonStrokingColor(c.red(), c.green(), c.blue()) + cs.setFont(font, size) + cs.newLineAtOffset(startX, startY) + lines.forEachIndexed { idx, line -> + if (idx > 0) cs.newLineAtOffset(0f, -leading) + runCatching { cs.showText(line) } + } + cs.endText() + } + + is ExportOverlay.NoteStamp -> { + // A real, clickable PDF sticky-note annotation. + val c = android.graphics.Color.valueOf(overlay.colorArgb) + val note = com.tom_roush.pdfbox.pdmodel.interactive.annotation.PDAnnotationText() + note.setContents(overlay.text) + note.name = com.tom_roush.pdfbox.pdmodel.interactive.annotation.PDAnnotationText.NAME_NOTE + note.color = com.tom_roush.pdfbox.pdmodel.graphics.color.PDColor( + floatArrayOf(c.red(), c.green(), c.blue()), + com.tom_roush.pdfbox.pdmodel.graphics.color.PDDeviceRGB.INSTANCE + ) + val ax = nx(overlay.position.x) + val ay = ny(overlay.position.y) + val iconSize = (min(pageW, pageH) * 0.03f).coerceIn(14f, 28f) + note.rectangle = com.tom_roush.pdfbox.pdmodel.common.PDRectangle(ax, ay - iconSize, iconSize, iconSize) + page.annotations.add(note) + } } + } catch (_: Exception) { + // Silently skip overlay that fails to render + } + } + + override fun onCleared() { + recycleBitmaps(_uiState.value.pageBitmaps) + renderingPages.clear() + renderedPageWidths.clear() + textLoadingPages.clear() + _uiState.value.document?.let { openPdfUseCase.close(it) } + super.onCleared() } private fun createEditedOutputUri(context: Context, targetFileName: String, overrideUri: Uri?): Uri { val targetPath = overrideUri ?: SaveLocationManager.getSaveUri(context) if (targetPath != null) { - try { + runCatching { val tree = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, targetPath) val created = tree?.createFile("application/pdf", targetFileName)?.uri if (created != null) return created - } catch (_: Exception) { - // Fallback to default location below. } } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val values = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName) @@ -721,151 +991,44 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel } } - private fun applyOverlays(bitmap: Bitmap, overlays: List) { - if (overlays.isEmpty()) return - - val canvas = Canvas(bitmap) - val width = bitmap.width.toFloat().coerceAtLeast(1f) - val height = bitmap.height.toFloat().coerceAtLeast(1f) - val minDim = min(width, height) - - overlays.forEach { overlay -> - when (overlay) { - is ExportOverlay.Stroke -> { - if (overlay.points.size < 2) return@forEach - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = applyAlpha(overlay.colorArgb, overlay.alpha) - style = Paint.Style.STROKE - strokeWidth = (overlay.widthNorm * minDim).coerceAtLeast(1f) - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - } - val path = Path() - overlay.points.forEachIndexed { idx, point -> - val x = point.x.coerceIn(0f, 1f) * width - val y = point.y.coerceIn(0f, 1f) * height - if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y) - } - canvas.drawPath(path, paint) - } - - is ExportOverlay.RectShape -> { - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = applyAlpha(overlay.colorArgb, overlay.alpha) - style = if (overlay.filled) Paint.Style.FILL else Paint.Style.STROKE - strokeWidth = (0.0045f * minDim).coerceAtLeast(1f) - } - val rect = normalizedRect( - overlay.start.x, - overlay.start.y, - overlay.end.x, - overlay.end.y, - width, - height - ) - canvas.drawRect(rect, paint) - } - - is ExportOverlay.OvalShape -> { - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = applyAlpha(overlay.colorArgb, overlay.alpha) - style = if (overlay.filled) Paint.Style.FILL else Paint.Style.STROKE - strokeWidth = (0.0045f * minDim).coerceAtLeast(1f) - } - val rect = normalizedRect( - overlay.start.x, - overlay.start.y, - overlay.end.x, - overlay.end.y, - width, - height - ) - canvas.drawOval(rect, paint) - } - - is ExportOverlay.LineShape -> { - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = applyAlpha(overlay.colorArgb, overlay.alpha) - style = Paint.Style.STROKE - strokeWidth = (overlay.widthNorm * minDim).coerceAtLeast(1f) - strokeCap = Paint.Cap.ROUND - } - val startX = overlay.start.x.coerceIn(0f, 1f) * width - val startY = overlay.start.y.coerceIn(0f, 1f) * height - val endX = overlay.end.x.coerceIn(0f, 1f) * width - val endY = overlay.end.y.coerceIn(0f, 1f) * height - canvas.drawLine(startX, startY, endX, endY, paint) - - if (overlay.arrowHead) { - val angle = atan2((endY - startY).toDouble(), (endX - startX).toDouble()) - val headLength = (0.03f * minDim).coerceAtLeast(10f).toDouble() - val theta = 30.0 * PI / 180.0 - val x1 = endX - (headLength * cos(angle - theta)).toFloat() - val y1 = endY - (headLength * sin(angle - theta)).toFloat() - val x2 = endX - (headLength * cos(angle + theta)).toFloat() - val y2 = endY - (headLength * sin(angle + theta)).toFloat() - canvas.drawLine(endX, endY, x1, y1, paint) - canvas.drawLine(endX, endY, x2, y2, paint) - } - } - - is ExportOverlay.ImageStamp -> { - val dst = normalizedRect( - overlay.start.x, overlay.start.y, overlay.end.x, overlay.end.y, width, height - ) - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { isFilterBitmap = true } - if (!overlay.bitmap.isRecycled) { - canvas.drawBitmap(overlay.bitmap, null, dst, paint) - } - } - } + private fun queryFileName(context: Context, uri: Uri): String? = runCatching { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx >= 0) cursor.getString(idx) else null + } else null } - } + }.getOrElse { null } - private fun applyAlpha(color: Int, alpha: Float): Int { - val baseAlpha = android.graphics.Color.alpha(color) - val scaled = (baseAlpha * alpha.coerceIn(0f, 1f)).toInt().coerceIn(0, 255) - return (color and 0x00FFFFFF) or (scaled shl 24) + private fun recycleBitmaps(bitmaps: List) { + bitmaps.forEach { if (it != null && !it.isRecycled) it.recycle() } } +} - private fun normalizedRect( - startX: Float, - startY: Float, - endX: Float, - endY: Float, - width: Float, - height: Float - ): android.graphics.RectF { - val x1 = startX.coerceIn(0f, 1f) * width - val y1 = startY.coerceIn(0f, 1f) * height - val x2 = endX.coerceIn(0f, 1f) * width - val y2 = endY.coerceIn(0f, 1f) * height - return android.graphics.RectF( - min(x1, x2), - min(y1, y2), - kotlin.math.max(x1, x2), - kotlin.math.max(y1, y2) - ) - } +private fun OcrTextBlock.fullTextRange(): OcrTextRange = + OcrTextRange(id, 0, text.length) - private fun queryFileName(context: Context, uri: Uri): String? { - return try { - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (idx >= 0) cursor.getString(idx) else null - } else null - } - } catch (_: Exception) { - null - } - } +private fun PdfTextBlock.toOcrBlock() = OcrTextBlock( + id = id, text = text, left = left, top = top, right = right, bottom = bottom, + charLefts = charLefts, charRights = charRights +) - private fun recycleBitmaps(bitmaps: List) { - bitmaps.forEach { bitmap -> - if (bitmap != null && !bitmap.isRecycled) { - bitmap.recycle() - } +/** All occurrences of [lower] in this block as tight normalized word rects (fallback: block rect). */ +private fun OcrTextBlock.findMatches(lower: String, page: Int): List { + if (lower.isEmpty()) return emptyList() + val bt = text.lowercase() + val out = ArrayList() + var from = 0 + while (true) { + val idx = bt.indexOf(lower, from) + if (idx < 0) break + val endC = idx + lower.length - 1 + if (charLefts.isNotEmpty() && idx < charLefts.size && endC < charRights.size) { + out.add(FindMatch(page, charLefts[idx], top, charRights[endC], bottom)) + } else { + out.add(FindMatch(page, left, top, right, bottom)) } + from = idx + lower.length } + return out } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SpreadsheetViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SpreadsheetViewModel.kt new file mode 100644 index 0000000..bb871ab --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SpreadsheetViewModel.kt @@ -0,0 +1,133 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.RecentFile +import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.utils.SpreadsheetParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +/** Loads a spreadsheet (.xlsx/.xls) into structured sheets for the interactive viewer. */ +class SpreadsheetViewModel : ViewModel() { + + data class UiState( + val fileName: String = "", + val fileUri: Uri? = null, // local (mirrored) copy, for sharing + val sheets: List = emptyList(), + val isLoading: Boolean = true, + val error: String? = null, + /** True once any cell has been edited in this session. */ + val isEdited: Boolean = false + ) + + private val _state = MutableStateFlow(UiState()) + val state = _state.asStateFlow() + private var started = false + + /** + * Opening a spreadsheet must not be able to kill the app. Everything below runs inside + * `viewModelScope.launch`, where an escaping exception is uncaught and fatal β€” and three of the + * calls here talk to a `ContentProvider` the app does not own (`query`, `openInputStream`) or + * to shared preferences, any of which can throw on a URI whose grant has lapsed. Each step is + * therefore individually guarded and degrades to the on-screen error state instead. + */ + fun load(context: Context, uri: Uri) { + if (started) return + started = true + viewModelScope.launch { + val name = withContext(Dispatchers.IO) { + runCatching { queryName(context, uri) }.getOrNull() + ?: uri.lastPathSegment ?: "Spreadsheet" + } + val (sheets, localUri) = withContext(Dispatchers.IO) { + // Mirror to app cache so the file stays readable when reopened from recents (a picked + // content:// URI may lose permission later), then parse from the local copy. + val local = runCatching { mirrorToCache(context, uri, name) }.getOrDefault(uri) + SpreadsheetParser.parse(context, local) to local + } + _state.value = if (sheets.isEmpty()) { + UiState(fileName = name, fileUri = localUri, isLoading = false, error = "Couldn't read this spreadsheet.") + } else { + UiState(fileName = name, fileUri = localUri, sheets = sheets, isLoading = false) + } + if (sheets.isNotEmpty()) withContext(Dispatchers.IO) { + // The picked uri, not `localUri`. The mirror gets a fresh `System.currentTimeMillis()` + // filename on every open, so keying recents on it meant `addRecent` could never + // recognise a repeat and one spreadsheet accumulated a row per open. The mirror is a + // cache artifact; the document's identity is the uri the user chose. Re-opening from + // recents re-mirrors, which is what the mirror is for. + runCatching { + RecentFilesManager.addRecent( + context, + RecentFile( + name = name, + uriString = uri.toString(), + timestamp = System.currentTimeMillis(), + pageCount = sheets.size + ) + ) + } + } + } + } + + /** + * Overwrite one cell. The edit lives in memory for this viewing session β€” the source .xlsx is + * not rewritten, because the only POI artifact on the classpath is `poi` 3.17 (HSSF/.xls), with + * no OOXML writer, so persisting would mean regenerating the workbook from scratch and dropping + * every formula, style and merge the original carries. + * + * Rows are padded out to [col] so a cell past the parsed width of its row can still be filled. + */ + fun updateCell(sheetIndex: Int, row: Int, col: Int, value: String) { + val sheets = _state.value.sheets + val sheet = sheets.getOrNull(sheetIndex) ?: return + if (row !in sheet.rows.indices) return + + val newRows = sheet.rows.toMutableList() + val cells = newRows[row].toMutableList() + while (cells.size <= col) cells.add("") + cells[col] = value + newRows[row] = cells + + val newSheets = sheets.toMutableList() + newSheets[sheetIndex] = sheet.copy(rows = newRows) + _state.value = _state.value.copy(sheets = newSheets, isEdited = true) + } + + /** Convert the spreadsheet to a PDF (reuses the doc converter) and hand back its URI. */ + fun exportToPdf(context: Context, onDone: (Uri?) -> Unit) { + val uri = _state.value.fileUri ?: return onDone(null) + viewModelScope.launch { + val out = withContext(Dispatchers.IO) { + runCatching { com.chethan616.clearpdf.utils.UniversalDocumentConverter.convertToPdf(context, uri) }.getOrNull() + } + onDone(out) + } + } + + private fun mirrorToCache(context: Context, uri: Uri, name: String): Uri { + val dir = File(context.cacheDir, "sheets").apply { mkdirs() } + val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "sheet.xlsx" } + val file = File(dir, "${System.currentTimeMillis()}_$safe") + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(file).use { input.copyTo(it) } + } ?: return uri + if (file.length() == 0L) { file.delete(); return uri } + return Uri.fromFile(file) + } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { c -> + val i = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (i != -1 && c.moveToFirst()) c.getString(i) else null + } ?: uri.lastPathSegment ?: "Spreadsheet" +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/WatermarkPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/WatermarkPdfViewModel.kt new file mode 100644 index 0000000..948f807 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/WatermarkPdfViewModel.kt @@ -0,0 +1,161 @@ +package com.chethan616.clearpdf.ui.viewmodel + +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.chethan616.clearpdf.data.repository.RecentFile +import com.chethan616.clearpdf.data.repository.RecentFilesManager +import com.chethan616.clearpdf.data.repository.SaveLocationManager +import com.kyant.pdfcore.watermark.PdfWatermarker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +enum class WatermarkMode { TEXT, IMAGE } + +data class WatermarkUiState( + val sourceUri: Uri? = null, + val sourceName: String = "", + val mode: WatermarkMode = WatermarkMode.TEXT, + val text: String = "CONFIDENTIAL", + val imageUri: Uri? = null, + val imageName: String = "", + val opacity: Float = 0.25f, + val diagonal: Boolean = true, + val isProcessing: Boolean = false, + val lastOutputUri: Uri? = null, + val resultMessage: String? = null, + val errorMessage: String? = null +) + +class WatermarkPdfViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(WatermarkUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onSelectFile(context: Context, uri: Uri) { + try { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + _uiState.update { + it.copy( + sourceUri = uri, + sourceName = queryName(context, uri), + lastOutputUri = null, + resultMessage = null, + errorMessage = null + ) + } + } + + fun onModeChange(mode: WatermarkMode) = _uiState.update { it.copy(mode = mode, lastOutputUri = null, resultMessage = null, errorMessage = null) } + fun onTextChange(value: String) = _uiState.update { it.copy(text = value) } + fun onOpacityChange(value: Float) = _uiState.update { it.copy(opacity = value.coerceIn(0.05f, 1f)) } + fun onDiagonalChange(value: Boolean) = _uiState.update { it.copy(diagonal = value) } + fun onPickImage(context: Context, uri: Uri) = + _uiState.update { it.copy(imageUri = uri, imageName = queryName(context, uri), lastOutputUri = null, resultMessage = null, errorMessage = null) } + + fun apply(context: Context) { + val src = _uiState.value.sourceUri ?: return + if (_uiState.value.isProcessing) return + val s0 = _uiState.value + if (s0.mode == WatermarkMode.TEXT && s0.text.isBlank()) { + _uiState.update { it.copy(errorMessage = "Enter watermark text") } + return + } + if (s0.mode == WatermarkMode.IMAGE && s0.imageUri == null) { + _uiState.update { it.copy(errorMessage = "Pick a watermark image") } + return + } + _uiState.update { it.copy(isProcessing = true, errorMessage = null, resultMessage = null, lastOutputUri = null) } + viewModelScope.launch { + try { + val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val fileName = "ClearPDF_Watermarked_$ts.pdf" + val saveLabel = SaveLocationManager.getSavePathDisplay(context) + val outUri = createOutputUri(context, fileName) + val s = _uiState.value + withContext(Dispatchers.IO) { + if (s.mode == WatermarkMode.IMAGE) { + val bmp = s.imageUri?.let { u -> + context.contentResolver.openInputStream(u)?.use { android.graphics.BitmapFactory.decodeStream(it) } + } ?: throw IllegalStateException("Couldn't read the watermark image") + PdfWatermarker.applyImage( + context = context, sourceUri = src, destinationUri = outUri, + bitmap = bmp, opacity = s.opacity, diagonal = s.diagonal + ) + } else { + PdfWatermarker.apply( + context = context, sourceUri = src, destinationUri = outUri, + text = s.text.trim(), opacity = s.opacity, diagonal = s.diagonal + ) + } + } + RecentFilesManager.addRecent(context, RecentFile( + name = fileName, uriString = outUri.toString(), + timestamp = System.currentTimeMillis(), sizeBytes = 0 + )) + _uiState.update { + it.copy( + isProcessing = false, + lastOutputUri = outUri, + resultMessage = "Watermark applied Β· saved to $saveLabel" + ) + } + } catch (t: Throwable) { + _uiState.update { it.copy(isProcessing = false, errorMessage = t.message ?: "Couldn't apply watermark") } + } + } + } + + fun clearFeedback() = _uiState.update { it.copy(errorMessage = null, resultMessage = null) } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx != -1 && cursor.moveToFirst()) cursor.getString(idx) else null + } ?: uri.lastPathSegment ?: "document.pdf" + + private fun createOutputUri(context: Context, fileName: String): Uri { + val customUri = SaveLocationManager.getSaveUri(context) + if (customUri != null) { + return try { + val docUri = androidx.documentfile.provider.DocumentFile.fromTreeUri(context, customUri) + docUri?.createFile("application/pdf", fileName)?.uri ?: createDownloadUri(context, fileName) + } catch (_: Exception) { createDownloadUri(context, fileName) } + } + return createDownloadUri(context, fileName) + } + + private fun createDownloadUri(context: Context, fileName: String): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val cv = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, cv) + ?: throw IllegalStateException("Unable to create output in Downloads") + } else { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + if (!dir.exists()) dir.mkdirs() + val file = java.io.File(dir, fileName) + if (!file.exists()) file.createNewFile() + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/util/HtmlToPdfConverter.kt b/app/src/main/java/com/chethan616/clearpdf/util/HtmlToPdfConverter.kt new file mode 100644 index 0000000..543dfbb --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/util/HtmlToPdfConverter.kt @@ -0,0 +1,95 @@ +package com.chethan616.clearpdf.util + +import android.content.Context +import android.graphics.pdf.PdfDocument +import android.net.Uri +import android.view.View +import android.webkit.WebView +import android.webkit.WebViewClient + +/** + * Converts a web page (URL) or a local HTML string to a paginated PDF on-device: an offscreen + * [WebView] lays the content out at A4 width, then each page-height slice is drawn onto a + * [PdfDocument] page. + * + * - [convertUrl] fetches a user-entered URL (JavaScript enabled) β€” the only place the app uses + * the network. + * - [convertHtml] renders self-contained HTML with JS disabled and no base URL (fully offline). + * + * MUST be called on the main thread (WebView requirement). [onDone] is invoked on the main + * thread with true on success. + */ +object HtmlToPdfConverter { + + // A4 at ~96 dpi. + private const val PAGE_W = 794 + private const val PAGE_H = 1123 + + fun convertHtml(context: Context, html: String, outputUri: Uri, onDone: (Boolean) -> Unit) { + val webView = WebView(context) + webView.settings.javaScriptEnabled = false + finishOnLoad(webView, context, outputUri, settleMs = 250, onDone) + webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) + } + + fun convertUrl(context: Context, url: String, outputUri: Uri, onDone: (Boolean) -> Unit) { + val webView = WebView(context) + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + loadWithOverviewMode = true + useWideViewPort = true + } + // JS-heavy pages need a longer settle before the layout is stable enough to snapshot. + finishOnLoad(webView, context, outputUri, settleMs = 900, onDone) + webView.loadUrl(url) + } + + private fun finishOnLoad(webView: WebView, context: Context, outputUri: Uri, settleMs: Long, onDone: (Boolean) -> Unit) { + var handled = false + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + if (handled) return + view.postDelayed({ + if (handled) return@postDelayed + handled = true + val ok = runCatching { render(view, context, outputUri) }.getOrDefault(false) + runCatching { view.destroy() } + onDone(ok) + }, settleMs) + } + } + } + + private fun render(view: WebView, context: Context, outputUri: Uri): Boolean { + view.measure( + View.MeasureSpec.makeMeasureSpec(PAGE_W, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + val contentHeight = view.measuredHeight.coerceAtLeast(1) + view.layout(0, 0, PAGE_W, contentHeight) + + val doc = PdfDocument() + var y = 0 + var pageNum = 1 + try { + while (y < contentHeight) { + val pageInfo = PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageNum).create() + val page = doc.startPage(pageInfo) + val canvas = page.canvas + canvas.save() + canvas.translate(0f, -y.toFloat()) + view.draw(canvas) + canvas.restore() + doc.finishPage(page) + y += PAGE_H + pageNum++ + if (pageNum > 500) break // safety cap + } + context.contentResolver.openOutputStream(outputUri)?.use { doc.writeTo(it) } ?: return false + } finally { + doc.close() + } + return true + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/DocKind.kt b/app/src/main/java/com/chethan616/clearpdf/utils/DocKind.kt new file mode 100644 index 0000000..2e9712f --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/utils/DocKind.kt @@ -0,0 +1,23 @@ +package com.chethan616.clearpdf.utils + +/** + * The original document family, derived from a file name's extension. The app converts non-PDF + * documents to a PDF for viewing (which loses the original type), but the original display name is + * preserved (e.g. "budget.xlsx") β€” so the extension is a reliable way to recover the kind and show + * the right editor tools / route to the right viewer. + */ +enum class DocKind { Pdf, Word, Excel, Ppt, Image, Other } + +/** Classify a file by its name/extension. Null/unknown β†’ [DocKind.Other]. */ +fun docKindOf(fileName: String?): DocKind { + val n = (fileName ?: "").lowercase().trim() + return when { + n.endsWith(".pdf") -> DocKind.Pdf + n.endsWith(".docx") || n.endsWith(".doc") || n.endsWith(".odt") || n.endsWith(".rtf") -> DocKind.Word + n.endsWith(".xlsx") || n.endsWith(".xls") -> DocKind.Excel + n.endsWith(".pptx") || n.endsWith(".ppt") -> DocKind.Ppt + n.endsWith(".png") || n.endsWith(".jpg") || n.endsWith(".jpeg") || + n.endsWith(".webp") || n.endsWith(".bmp") || n.endsWith(".heic") -> DocKind.Image + else -> DocKind.Other + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/SpreadsheetParser.kt b/app/src/main/java/com/chethan616/clearpdf/utils/SpreadsheetParser.kt new file mode 100644 index 0000000..9f24e9c --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/utils/SpreadsheetParser.kt @@ -0,0 +1,238 @@ +package com.chethan616.clearpdf.utils + +import android.content.Context +import android.net.Uri +import android.util.Xml +import org.apache.poi.hssf.usermodel.HSSFWorkbook +import org.xmlpull.v1.XmlPullParser +import java.io.InputStream +import java.util.zip.ZipInputStream + +/** + * Parses .xlsx / .xls spreadsheets into structured, cell-aligned rows per sheet β€” for the + * interactive spreadsheet viewer (as opposed to [UniversalDocumentConverter], which flattens to a + * static PDF). Cells are placed at their true column index (from the r="C5" ref) so omitted/empty + * cells don't shift data, and inline strings + shared strings are both handled. Self-contained. + */ +object SpreadsheetParser { + + /** Widest row this parser will materialise. See the note at the `"c"` end-tag. */ + private const val MaxColumns = 1024 + + data class Sheet(val name: String, val rows: List>) { + /** Widest row β†’ number of columns to render. */ + val columnCount: Int get() = rows.maxOfOrNull { it.size } ?: 0 + } + + /** + * Never throws. Every failure β€” no such file, a revoked URI permission, a corrupt archive, a + * workbook too large for the heap β€” comes back as an empty list, which the caller renders as + * "Couldn't read this spreadsheet." + * + * The `runCatching` used to wrap only [parseXlsx]/[parseXls], leaving `openInputStream` and the + * whole-file read outside it. Those are the two calls most likely to fail (a `SecurityException` + * when a picked URI's grant has lapsed, an `OutOfMemoryError` on a big workbook), and because + * the caller invokes this from `viewModelScope.launch`, anything escaping here took the process + * down rather than showing the error state. + */ + fun parse(context: Context, uri: Uri): List = runCatching { + val name = queryName(context, uri).lowercase() + if (name.endsWith(".xls")) { + // POI's HSSF reader wants the file in hand; there is no streaming path for it. + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes == null) emptyList() else parseXls(bytes) + } else { + context.contentResolver.openInputStream(uri)?.use { parseXlsx(it) } ?: emptyList() + } + }.getOrDefault(emptyList()) + + // ── XLSX (Office Open XML) ─────────────────────────────────────────────────── + + fun parseXlsx(bytes: ByteArray): List = bytes.inputStream().use { parseXlsx(it) } + + fun parseXlsx(source: InputStream): List { + val entries = HashMap() + ZipInputStream(source).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + // Only the four things below are ever read again. Keeping the rest was the single + // biggest allocation in this parser: `xl/media/*` (embedded images, already the + // bulk of many workbooks) and `xl/calcChain.xml` (one node per formula cell, often + // larger than the sheets themselves) were being decompressed into the heap in full + // and never touched. Skipping them is what keeps a mid-size workbook off the OOM + // line, since every entry kept here stays reachable until parsing finishes. + if (isNeeded(entry.name)) entries[entry.name] = zip.readBytes() + } + } + val sharedStrings = entries["xl/sharedStrings.xml"]?.let { parseSharedStrings(it.inputStream()) } ?: emptyList() + val workbookSheets = parseWorkbookSheets(entries["xl/workbook.xml"]) // (name, rId) in tab order + val rels = parseRels(entries["xl/_rels/workbook.xml.rels"]) // rId β†’ "worksheets/sheetN.xml" + + val ordered: List> = if (workbookSheets.isNotEmpty()) { + workbookSheets.mapNotNull { (name, rId) -> + val target = rels[rId] ?: return@mapNotNull null + val path = if (target.startsWith("/")) target.drop(1) else "xl/${target.removePrefix("/")}" + entries[path]?.let { name to it } + } + } else { + entries.keys.filter { it.startsWith("xl/worksheets/sheet") && it.endsWith(".xml") } + .sortedBy { Regex("sheet(\\d+)\\.xml").find(it)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: Int.MAX_VALUE } + .map { (Regex("sheet(\\d+)").find(it)?.groupValues?.getOrNull(1)?.let { n -> "Sheet $n" } ?: "Sheet") to entries[it]!! } + } + + return ordered.map { (name, xml) -> Sheet(name, parseWorksheetRows(xml.inputStream(), sharedStrings)) } + .filter { it.rows.isNotEmpty() } + } + + private fun parseWorkbookSheets(bytes: ByteArray?): List> { + if (bytes == null) return emptyList() + val out = mutableListOf>() + val parser = newParser(bytes.inputStream()) + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "sheet") { + val name = parser.getAttributeValue(null, "name") ?: "Sheet" + val rId = parser.getAttributeValue(null, "r:id") ?: parser.getAttributeValue(null, "id") ?: "" + out.add(name to rId) + } + event = parser.next() + } + return out + } + + private fun parseRels(bytes: ByteArray?): Map { + if (bytes == null) return emptyMap() + val out = HashMap() + val parser = newParser(bytes.inputStream()) + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "Relationship") { + val id = parser.getAttributeValue(null, "Id") ?: "" + val target = parser.getAttributeValue(null, "Target") ?: "" + if (id.isNotEmpty()) out[id] = target + } + event = parser.next() + } + return out + } + + private fun parseSharedStrings(stream: InputStream): List { + val strings = mutableListOf() + val parser = newParser(stream) + var inT = false + val buf = StringBuilder() + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> if (parser.name == "si") buf.clear() else if (parser.name == "t") inT = true + XmlPullParser.END_TAG -> if (parser.name == "si") { strings.add(buf.toString()); inT = false } else if (parser.name == "t") inT = false + XmlPullParser.TEXT -> if (inT) buf.append(parser.text) + } + event = parser.next() + } + return strings + } + + private fun colIndexFromRef(ref: String?): Int { + if (ref.isNullOrEmpty()) return -1 + var idx = 0 + var sawLetter = false + for (ch in ref) { + val up = ch.uppercaseChar() + if (up in 'A'..'Z') { idx = idx * 26 + (up - 'A' + 1); sawLetter = true } else break + } + return if (sawLetter) idx - 1 else -1 + } + + private fun parseWorksheetRows(stream: InputStream, sharedStrings: List): List> { + val rows = mutableListOf>() + val parser = newParser(stream) + var rowCells = sortedMapOf() + var cellType = "" + var cellCol = 0 + var nextAutoCol = 0 + var inVal = false + val cellBuf = StringBuilder() + + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> when (parser.name) { + "row" -> { rowCells = sortedMapOf(); nextAutoCol = 0 } + "c" -> { + cellType = parser.getAttributeValue(null, "t") ?: "" + cellCol = colIndexFromRef(parser.getAttributeValue(null, "r")).let { if (it >= 0) it else nextAutoCol } + inVal = false; cellBuf.clear() + } + "v", "t" -> inVal = true + } + XmlPullParser.END_TAG -> when (parser.name) { + "row" -> if (rowCells.isNotEmpty()) { + val maxC = rowCells.lastKey() + rows.add((0..maxC).map { rowCells[it] ?: "" }) + } else rows.add(emptyList()) + "c" -> { + val raw = cellBuf.toString() + val value = if (cellType == "s") sharedStrings.getOrElse(raw.trim().toIntOrNull() ?: -1) { raw } else raw + // The column cap is what stops one malformed `r` ref from sizing the whole + // sheet: the row is materialised as a dense `0..maxKey` list, so a single + // cell claiming to be at XFD would allocate 16384 strings for every row in + // the file. Excel's own classic limit is 256; past this a phone grid is not + // a usable way to read the data anyway. + if (value.isNotEmpty() && cellCol in 0 until MaxColumns) rowCells[cellCol] = value + nextAutoCol = cellCol + 1 + inVal = false + } + "v", "t" -> inVal = false + } + XmlPullParser.TEXT -> if (inVal) cellBuf.append(parser.text) + } + event = parser.next() + if (rows.size > 20000) break + } + // Trim trailing fully-empty rows. + while (rows.isNotEmpty() && rows.last().all { it.isBlank() }) rows.removeAt(rows.lastIndex) + return rows + } + + // ── Legacy XLS (POI) ───────────────────────────────────────────────────────── + + private fun parseXls(bytes: ByteArray): List { + HSSFWorkbook(bytes.inputStream()).use { wb -> + return (0 until wb.numberOfSheets).map { si -> + val sheet = wb.getSheetAt(si) + val rows = sheet.map { row -> + val lastCol = row.lastCellNum.toInt().coerceAtLeast(0) + (0 until lastCol).map { c -> row.getCell(c)?.toString()?.trim() ?: "" } + } + Sheet(wb.getSheetName(si) ?: "Sheet ${si + 1}", rows) + }.filter { it.rows.isNotEmpty() } + } + } + + // ── helpers ────────────────────────────────────────────────────────────────── + + /** + * The zip entries this parser actually reads. Matched by suffix rather than by exact path + * because a few producers write the part names with a leading slash or a non-`xl/` package + * root, and a workbook that opens fine in Excel should not come up blank here. + */ + private fun isNeeded(entryName: String): Boolean { + val n = entryName.removePrefix("/") + return n.endsWith("workbook.xml") || + n.endsWith("workbook.xml.rels") || + n.endsWith("sharedStrings.xml") || + (n.contains("worksheets/") && n.endsWith(".xml")) + } + + private fun newParser(stream: InputStream): XmlPullParser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(stream, "UTF-8") + } + + private fun queryName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { c -> + val i = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (i != -1 && c.moveToFirst()) c.getString(i) else null + } ?: uri.lastPathSegment ?: "sheet" +} diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt b/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt index 0e84dd5..e8c0de6 100644 --- a/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt +++ b/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt @@ -1,246 +1,787 @@ package com.chethan616.clearpdf.utils import android.content.Context -import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Typeface import android.graphics.pdf.PdfDocument import android.net.Uri -import android.os.Environment -import androidx.core.content.FileProvider +import android.text.Layout +import android.text.SpannableStringBuilder +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import android.util.Xml +import org.apache.poi.hssf.usermodel.HSSFWorkbook +import org.apache.poi.hwpf.extractor.WordExtractor +import org.apache.poi.hslf.usermodel.HSLFSlideShow +import org.xmlpull.v1.XmlPullParser import java.io.BufferedReader import java.io.File import java.io.FileOutputStream +import java.io.InputStream import java.io.InputStreamReader import java.util.zip.ZipInputStream -import org.xmlpull.v1.XmlPullParser -import android.util.Xml -import org.apache.poi.hssf.usermodel.HSSFWorkbook -import org.apache.poi.hwpf.extractor.WordExtractor -import org.apache.poi.hslf.usermodel.HSLFSlideShow object UniversalDocumentConverter { + private const val PAGE_W = 595 + private const val PAGE_H = 842 + private const val MARGIN = 48f + private val TEXT_W get() = (PAGE_W - 2 * MARGIN).toInt() + + // ── Public API ───────────────────────────────────────────────────────────── + fun isPdf(context: Context, uri: Uri): Boolean { val type = context.contentResolver.getType(uri) if (type != null && type.contains("pdf", ignoreCase = true)) return true - val name = getFileName(context, uri) - return name.endsWith(".pdf", ignoreCase = true) + return getFileName(context, uri).endsWith(".pdf", ignoreCase = true) } fun convertToPdf(context: Context, sourceUri: Uri): Uri { val mimeType = context.contentResolver.getType(sourceUri) ?: "" val name = getFileName(context, sourceUri).lowercase() - return when { - mimeType.startsWith("image/") || name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".webp") || name.endsWith(".bmp") -> { + mimeType.startsWith("image/") || name.endsWithAny(".png", ".jpg", ".jpeg", ".webp", ".bmp", ".heic") -> convertImageToPdf(context, sourceUri) - } - mimeType.startsWith("text/") || name.endsWith(".txt") || name.endsWith(".csv") || name.endsWith(".log") -> { + name.endsWith(".docx") -> convertZipXmlToPdf(context, sourceUri, DocFlavor.DOCX) + name.endsWith(".xlsx") -> convertZipXmlToPdf(context, sourceUri, DocFlavor.XLSX) + name.endsWith(".pptx") -> convertZipXmlToPdf(context, sourceUri, DocFlavor.PPTX) + name.endsWith(".odt") -> convertZipXmlToPdf(context, sourceUri, DocFlavor.ODT) + name.endsWith(".doc") -> convertLegacyWordToPdf(context, sourceUri) + name.endsWith(".xls") -> convertLegacyXlsToPdf(context, sourceUri) + name.endsWith(".ppt") -> convertLegacyPptToPdf(context, sourceUri) + mimeType.startsWith("text/") || name.endsWithAny(".txt", ".csv", ".log", ".rtf", ".md") -> convertTextToPdf(context, sourceUri) - } - else -> { - // Fallback for docx/xlsx/other formats: extract readable text or convert bitmap snapshot - convertGenericToPdf(context, sourceUri) - } + else -> convertTextToPdf(context, sourceUri) } } - private fun convertImageToPdf(context: Context, sourceUri: Uri): Uri { - val inputStream = context.contentResolver.openInputStream(sourceUri) - ?: throw IllegalStateException("Unable to open image stream") - val bitmap = BitmapFactory.decodeStream(inputStream) - ?: throw IllegalStateException("Invalid image file") + // ── Image ────────────────────────────────────────────────────────────────── + private fun convertImageToPdf(context: Context, sourceUri: Uri): Uri { + val bitmap = context.contentResolver.openInputStream(sourceUri).use { + BitmapFactory.decodeStream(it) ?: throw IllegalStateException("Invalid image") + } val pdfDoc = PdfDocument() - val pageInfo = PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, 1).create() - val page = pdfDoc.startPage(pageInfo) + val page = pdfDoc.startPage(PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, 1).create()) page.canvas.drawBitmap(bitmap, 0f, 0f, null) pdfDoc.finishPage(page) - - val outputFile = createTempPdfFile(context, "Image_Converted") - FileOutputStream(outputFile).use { pdfDoc.writeTo(it) } - pdfDoc.close() bitmap.recycle() - - return Uri.fromFile(outputFile) + return writePdf(context, pdfDoc, "Image") } + // ── Plain text / CSV ─────────────────────────────────────────────────────── + private fun convertTextToPdf(context: Context, sourceUri: Uri): Uri { - val inputStream = context.contentResolver.openInputStream(sourceUri) - ?: throw IllegalStateException("Unable to open text file") - val reader = BufferedReader(InputStreamReader(inputStream)) - val lines = reader.readLines() + val lines = context.contentResolver.openInputStream(sourceUri)?.use { stream -> + BufferedReader(InputStreamReader(stream)).readLines() + } ?: emptyList() - val pdfDoc = PdfDocument() - val pageWidth = 595 // A4 width - val pageHeight = 842 // A4 height - val margin = 40f - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = 12f - color = Color.BLACK - typeface = Typeface.MONOSPACE - } - - var pageNumber = 1 - var y = margin + 20f - var currentPage = pdfDoc.startPage(PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()) - var canvas = currentPage.canvas - - for (line in lines) { - if (y > pageHeight - margin) { - pdfDoc.finishPage(currentPage) - pageNumber++ - currentPage = pdfDoc.startPage(PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()) - canvas = currentPage.canvas - y = margin + 20f + val paint = bodyPaint(Typeface.MONOSPACE, 11f) + val blocks = lines.map { line -> + DocBlock.Para(SpannableStringBuilder(line), spaceAfter = 0f, lineSpacingMult = 1.15f) + } + return writePdf(context, renderBlocks(blocks, paint), "Text") + } + + // ── Office Open XML (docx / xlsx / pptx / odt) ──────────────────────────── + + private enum class DocFlavor { DOCX, XLSX, PPTX, ODT } + + private fun convertZipXmlToPdf(context: Context, sourceUri: Uri, flavor: DocFlavor): Uri { + val bytes = context.contentResolver.openInputStream(sourceUri)?.use { it.readBytes() } + ?: throw IllegalStateException("Cannot open file") + val paint = bodyPaint() + val blocks: List = when (flavor) { + DocFlavor.DOCX -> parseDocx(bytes) + DocFlavor.XLSX -> parseXlsx(bytes) + DocFlavor.PPTX -> parsePptx(bytes) + DocFlavor.ODT -> parseOdt(bytes) + } + val tag = flavor.name.lowercase().replaceFirstChar { it.uppercase() } + return writePdf(context, renderBlocks(blocks, paint), tag) + } + + // ── DOCX ─────────────────────────────────────────────────────────────────── + + private fun parseDocx(bytes: ByteArray): List { + // Read the whole package so we can resolve inline images (drawing β†’ r:embed β†’ rels β†’ media). + val entries = HashMap() + ZipInputStream(bytes.inputStream()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + entries[entry.name] = zip.readBytes() } - canvas.drawText(line.take(90), margin, y, paint) - y += 18f } - pdfDoc.finishPage(currentPage) + val docXml = entries["word/document.xml"] + ?: return listOf(DocBlock.Para(SpannableStringBuilder("(empty document)"))) + val rels = parseRels(entries["word/_rels/document.xml.rels"]) // rId β†’ "media/imageN.png" + val blocks = parseWordXml(docXml.inputStream(), rels, entries) + return blocks.ifEmpty { listOf(DocBlock.Para(SpannableStringBuilder("(empty document)"))) } + } + + private fun parseWordXml(stream: InputStream, rels: Map, entries: Map): List { + val result = mutableListOf() + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(stream, "UTF-8") + } - val outputFile = createTempPdfFile(context, "Text_Converted") - FileOutputStream(outputFile).use { pdfDoc.writeTo(it) } - pdfDoc.close() + var inBody = false + var inPara = false + var inParaProps = false + var inRun = false + var inRunProps = false + var inTbl = false + var tblRows = mutableListOf>() + var tblRow = mutableListOf() + var tblCell = StringBuilder() + var inTblCell = false + + var headingLevel = 0 + var align = Layout.Alignment.ALIGN_NORMAL + var justify = false + // w:ind / w:spacing are in twips (1/20 pt). -1 = "not specified" so a real 0 still overrides. + var leftIndentTwips = 0 + var spaceBeforeTwips = -1 + var spaceAfterTwips = -1 + var bold = false + var italic = false + var underline = false + var runColor = 0 // 0 = "no explicit colour" sentinel (a real colour is 0xFF…) + var fontHalfPt = 0 // w:sz is in half-points; 0 = default + var paraBuf = StringBuilder() + data class Span(val start: Int, val end: Int, val span: Any) + var spans = mutableListOf() + + fun flushPara() { + // trimEnd only β€” leading indentation and intentional spacing survive (Word carries some + // indent as literal runs, and trimming both ends flattened them). Real indent still comes + // from w:ind below. + val text = paraBuf.toString().trimEnd() + val leftIndentPt = leftIndentTwips / 20f + val spaceBefore = if (spaceBeforeTwips >= 0) spaceBeforeTwips / 20f else 0f + val spaceAfter = when { + spaceAfterTwips >= 0 -> spaceAfterTwips / 20f + headingLevel > 0 -> 10f + else -> 6f + } + if (text.isNotEmpty()) { + val ssb = SpannableStringBuilder(text) + if (headingLevel > 0) ssb.setSpan(StyleSpan(Typeface.BOLD), 0, ssb.length, 0) + spans.forEach { s -> ssb.setSpan(s.span, s.start.coerceAtMost(ssb.length), s.end.coerceAtMost(ssb.length), 0) } + result.add(DocBlock.Para( + ssb, headingLevel = headingLevel, spaceAfter = spaceAfter, spaceBefore = spaceBefore, + alignment = align, leftIndent = leftIndentPt, justify = justify + )) + } else { + // Blank paragraph β†’ a blank line, so vertical spacing between blocks is preserved. + result.add(DocBlock.Para(SpannableStringBuilder(""), spaceAfter = 0f)) + } + paraBuf.clear(); spans.clear(); headingLevel = 0; align = Layout.Alignment.ALIGN_NORMAL + justify = false; leftIndentTwips = 0; spaceBeforeTwips = -1; spaceAfterTwips = -1 + bold = false; italic = false; underline = false; runColor = 0; fontHalfPt = 0 + } + + fun addImage(embedId: String?) { + val target = rels[embedId ?: return] ?: return + val path = if (target.startsWith("/")) target.drop(1) else "word/${target.removePrefix("/")}" + val imgBytes = entries[path] ?: return + val bmp = runCatching { BitmapFactory.decodeByteArray(imgBytes, 0, imgBytes.size) }.getOrNull() ?: return + flushPara() + result.add(DocBlock.ImageBlock(bmp)) + } + + // A boolean toggle prop (w:b / w:i / w:u) is ON unless it carries w:val="false"/"0"/"none". + fun toggleOn(): Boolean = parser.getAttributeValue(null, "w:val").let { it == null || it !in setOf("false", "0", "none") } - return Uri.fromFile(outputFile) + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + val name = if (event == XmlPullParser.START_TAG || event == XmlPullParser.END_TAG) parser.name else null + when (event) { + XmlPullParser.START_TAG -> when (name) { + "w:body" -> inBody = true + "w:p" -> if (inBody && !inTbl) { inPara = true } + "w:pPr" -> if (inPara) inParaProps = true + "w:pStyle"-> if (inParaProps) { + headingLevel = when (parser.getAttributeValue(null, "w:val")?.lowercase()) { + "heading1", "title", "toc heading" -> 1 + "heading2", "subtitle" -> 2 + "heading3" -> 3 + "heading4", "heading5", "heading6" -> 4 + else -> 0 + } + } + // Paragraph alignment. "both"/"distribute" = justified β€” kept left-aligned with the + // justify flag so the renderer can space the words out (setJustificationMode). + "w:jc" -> if (inParaProps) when (parser.getAttributeValue(null, "w:val")?.lowercase()) { + "center" -> { align = Layout.Alignment.ALIGN_CENTER; justify = false } + "right", "end" -> { align = Layout.Alignment.ALIGN_OPPOSITE; justify = false } + "both", "distribute" -> { align = Layout.Alignment.ALIGN_NORMAL; justify = true } + else -> { align = Layout.Alignment.ALIGN_NORMAL; justify = false } + } + // Paragraph indent (twips). w:start is the LTR alias for w:left in newer files. + "w:ind" -> if (inParaProps) { + (parser.getAttributeValue(null, "w:left") ?: parser.getAttributeValue(null, "w:start")) + ?.toIntOrNull()?.let { leftIndentTwips = it.coerceAtLeast(0) } + } + // Explicit paragraph spacing before/after (twips) overrides the defaults. + "w:spacing" -> if (inParaProps) { + parser.getAttributeValue(null, "w:before")?.toIntOrNull()?.let { spaceBeforeTwips = it } + parser.getAttributeValue(null, "w:after")?.toIntOrNull()?.let { spaceAfterTwips = it } + } + "w:r" -> if (inPara) { inRun = true; bold = false; italic = false; underline = false; runColor = 0; fontHalfPt = 0 } + "w:rPr" -> if (inRun) inRunProps = true + "w:b" -> if (inRunProps) bold = toggleOn() + "w:i" -> if (inRunProps) italic = toggleOn() + "w:u" -> if (inRunProps) underline = toggleOn() + "w:sz" -> if (inRunProps) fontHalfPt = parser.getAttributeValue(null, "w:val")?.toIntOrNull() ?: 0 + "w:color" -> if (inRunProps) runColor = parseHexColor(parser.getAttributeValue(null, "w:val")) + "w:t" -> if (inRun && inPara) { /* text event follows */ } + // Preserve tabs and in-paragraph line breaks so words don't run together. + "w:tab" -> if (inPara && !inTbl) paraBuf.append(" ") + "w:br", "w:cr" -> if (inPara && !inTbl) paraBuf.append("\n") + // Inline image: β†’ resolve via rels β†’ word/media/… bytes. + "a:blip" -> if (inPara && !inTbl) addImage(parser.getAttributeValue(null, "r:embed") ?: parser.getAttributeValue(null, "r:link")) + "w:tbl" -> if (inBody) { inTbl = true; tblRows = mutableListOf() } + "w:tr" -> if (inTbl) { tblRow = mutableListOf() } + "w:tc" -> if (inTbl) { inTblCell = true; tblCell.clear() } + } + XmlPullParser.END_TAG -> when (name) { + "w:body" -> inBody = false + "w:pPr" -> inParaProps = false + "w:rPr" -> inRunProps = false + "w:r" -> inRun = false + "w:p" -> if (inPara) { flushPara(); inPara = false } + "w:tbl" -> { + if (tblRows.isNotEmpty()) result.add(DocBlock.Table(tblRows)) + inTbl = false + } + "w:tr" -> if (inTbl) { tblRows.add(tblRow.toList()) } + "w:tc" -> if (inTbl) { + tblRow.add(tblCell.toString().trim()); inTblCell = false + } + } + XmlPullParser.TEXT -> { + val text = parser.text ?: "" + if (inRun && inPara && text.isNotEmpty()) { + val start = paraBuf.length + paraBuf.append(text) + val end = paraBuf.length + if (bold) spans.add(Span(start, end, StyleSpan(Typeface.BOLD))) + if (italic) spans.add(Span(start, end, StyleSpan(Typeface.ITALIC))) + if (underline) spans.add(Span(start, end, UnderlineSpan())) + if (runColor != 0) spans.add(Span(start, end, ForegroundColorSpan(runColor))) + if (fontHalfPt > 0) spans.add(Span(start, end, AbsoluteSizeSpan((fontHalfPt / 2f).toInt().coerceIn(6, 96)))) + } + if (inTblCell && text.isNotEmpty()) tblCell.append(text) + } + } + event = parser.next() + } + return result } - private fun convertGenericToPdf(context: Context, sourceUri: Uri): Uri { - val inputStream = context.contentResolver.openInputStream(sourceUri) - ?: throw IllegalStateException("Unable to open file") - val bytes = inputStream.use { it.readBytes() } - val fileName = getFileName(context, sourceUri).lowercase() - val structuredText = when { - fileName.endsWith(".docx") || fileName.endsWith(".pptx") || fileName.endsWith(".xlsx") || fileName.endsWith(".odt") -> - extractOfficeText(bytes.inputStream(), fileName) - fileName.endsWith(".doc") || fileName.endsWith(".xls") || fileName.endsWith(".ppt") -> - extractLegacyOfficeText(bytes.inputStream(), fileName) - else -> emptyList() + // ── XLSX ─────────────────────────────────────────────────────────────────── + + private fun parseXlsx(bytes: ByteArray): List { + // Read the whole package once so we can resolve workbook order + real sheet names via the + // rels β€” instead of guessing from the zip's arbitrary entry order. + val entries = HashMap() + ZipInputStream(bytes.inputStream()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + entries[entry.name] = zip.readBytes() + } } - val contentStr = if (structuredText.isNotEmpty()) { - structuredText.joinToString("\n") + val sharedStrings = entries["xl/sharedStrings.xml"]?.let { parseSharedStrings(it.inputStream()) } ?: emptyList() + + // Workbook defines sheets in TAB order with their names + an r:id β†’ the rels map that r:id + // to the actual sheetN.xml file. This gives correct order AND the human sheet name. + val workbookSheets = parseWorkbookSheets(entries["xl/workbook.xml"]) // (name, rId) in tab order + val rels = parseRels(entries["xl/_rels/workbook.xml.rels"]) // rId β†’ "worksheets/sheetN.xml" + val ordered: List> = if (workbookSheets.isNotEmpty()) { + workbookSheets.mapNotNull { (name, rId) -> + val target = rels[rId] ?: return@mapNotNull null + val path = if (target.startsWith("/")) target.drop(1) else "xl/${target.removePrefix("/")}" + entries[path]?.let { name to it } + } } else { - String(bytes, Charsets.UTF_8).filter { it.isISOControl().not() || it == '\n' || it == '\r' || it == '\t' } + // Fallback: every worksheet by numeric index, generic "Sheet N" names. + entries.keys.filter { it.startsWith("xl/worksheets/sheet") && it.endsWith(".xml") } + .sortedBy { Regex("sheet(\\d+)\\.xml").find(it)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: Int.MAX_VALUE } + .map { (Regex("sheet(\\d+)").find(it)?.groupValues?.getOrNull(1)?.let { n -> "Sheet $n" } ?: "Sheet") to entries[it]!! } } - val pdfDoc = PdfDocument() - val pageWidth = 595 - val pageHeight = 842 - val margin = 40f - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = 11f - color = Color.DKGRAY - typeface = Typeface.DEFAULT - } - - val lines = contentStr.lines().take(500) - var pageNumber = 1 - var y = margin + 20f - var currentPage = pdfDoc.startPage(PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()) - var canvas = currentPage.canvas - - for (line in lines) { - if (y > pageHeight - margin) { - pdfDoc.finishPage(currentPage) - pageNumber++ - currentPage = pdfDoc.startPage(PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()) - canvas = currentPage.canvas - y = margin + 20f + val result = mutableListOf() + for ((name, xml) in ordered) { + val rows = parseWorksheetRows(xml.inputStream(), sharedStrings) + if (rows.isEmpty()) continue + // A labelled header for each sheet so a multi-sheet workbook reads as clearly separated + // sections instead of one anonymous run of tables. + val header = SpannableStringBuilder(name) + header.setSpan(StyleSpan(Typeface.BOLD), 0, header.length, 0) + result.add(DocBlock.Para(header, headingLevel = 1, spaceAfter = 6f)) + result.add(DocBlock.Table(rows)) + } + return result.ifEmpty { listOf(DocBlock.Para(SpannableStringBuilder("(empty spreadsheet)"))) } + } + + /** Sheets in workbook (tab) order as (name, relationshipId). */ + private fun parseWorkbookSheets(bytes: ByteArray?): List> { + if (bytes == null) return emptyList() + val out = mutableListOf>() + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(bytes.inputStream(), "UTF-8") + } + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "sheet") { + val name = parser.getAttributeValue(null, "name") ?: "Sheet" + val rId = parser.getAttributeValue(null, "r:id") ?: parser.getAttributeValue(null, "id") ?: "" + out.add(name to rId) } - canvas.drawText(line.take(85), margin, y, paint) - y += 16f + event = parser.next() } - pdfDoc.finishPage(currentPage) + return out + } - val outputFile = createTempPdfFile(context, "Document_Converted") - FileOutputStream(outputFile).use { pdfDoc.writeTo(it) } - pdfDoc.close() + /** "#RRGGBB" / "RRGGBB" / "auto" β†’ ARGB int, or 0 (sentinel = none) when absent/invalid. */ + private fun parseHexColor(hex: String?): Int { + if (hex.isNullOrBlank() || hex.equals("auto", ignoreCase = true)) return 0 + return runCatching { Color.parseColor(if (hex.startsWith("#")) hex else "#$hex") }.getOrDefault(0) + } + + /** Relationship id β†’ target path from a .rels part. */ + private fun parseRels(bytes: ByteArray?): Map { + if (bytes == null) return emptyMap() + val out = HashMap() + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(bytes.inputStream(), "UTF-8") + } + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "Relationship") { + val id = parser.getAttributeValue(null, "Id") ?: "" + val target = parser.getAttributeValue(null, "Target") ?: "" + if (id.isNotEmpty()) out[id] = target + } + event = parser.next() + } + return out + } - return Uri.fromFile(outputFile) + private fun parseSharedStrings(stream: InputStream): List { + val strings = mutableListOf() + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(stream, "UTF-8") + } + var inT = false + val buf = StringBuilder() + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> if (parser.name == "si") buf.clear() + else if (parser.name == "t") inT = true + XmlPullParser.END_TAG -> if (parser.name == "si") { strings.add(buf.toString()); inT = false } + else if (parser.name == "t") inT = false + XmlPullParser.TEXT -> if (inT) buf.append(parser.text) + } + event = parser.next() + } + return strings } - /** - * Office Open XML files are ZIP containers. Reading their text nodes keeps the app - * dependency-light while still producing a useful, searchable PDF preview for Word, - * PowerPoint and Excel files. - */ - private fun extractOfficeText(input: java.io.InputStream, fileName: String): List { - val lines = mutableListOf() - ZipInputStream(input).use { zip -> + /** Converts a spreadsheet column reference ("A", "B", … "AA") from a cell ref like "AB12" to a + * 0-based column index. Returns -1 if there are no leading letters. */ + private fun colIndexFromRef(ref: String?): Int { + if (ref.isNullOrEmpty()) return -1 + var idx = 0 + var sawLetter = false + for (ch in ref) { + val up = ch.uppercaseChar() + if (up in 'A'..'Z') { idx = idx * 26 + (up - 'A' + 1); sawLetter = true } else break + } + return if (sawLetter) idx - 1 else -1 + } + + private fun parseWorksheetRows(stream: InputStream, sharedStrings: List): List> { + val rows = mutableListOf>() + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(stream, "UTF-8") + } + // Place every cell at its REAL column index (from the r="C5" ref) so omitted/empty cells β€” + // which OOXML simply leaves out β€” don't shift the rest of the row left. That left-shift was + // the main reason spreadsheet values looked "missing" or landed under the wrong header here. + var rowCells = sortedMapOf() + var cellType = "" + var cellCol = 0 + var nextAutoCol = 0 + var inVal = false // inside (value) or inline (inline string text) + val cellBuf = StringBuilder() + + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> when (parser.name) { + "row" -> { rowCells = sortedMapOf(); nextAutoCol = 0 } + "c" -> { + cellType = parser.getAttributeValue(null, "t") ?: "" + cellCol = colIndexFromRef(parser.getAttributeValue(null, "r")).let { if (it >= 0) it else nextAutoCol } + inVal = false; cellBuf.clear() + } + "v", "t" -> inVal = true // here = an inline-string cell (…) + } + XmlPullParser.END_TAG -> when (parser.name) { + "row" -> if (rowCells.isNotEmpty()) { + val maxC = rowCells.lastKey() + rows.add((0..maxC).map { rowCells[it] ?: "" }) + } + "c" -> { + val raw = cellBuf.toString() + val value = if (cellType == "s") sharedStrings.getOrElse(raw.trim().toIntOrNull() ?: -1) { raw } else raw + if (value.isNotEmpty()) rowCells[cellCol] = value + nextAutoCol = cellCol + 1 + inVal = false + } + "v", "t" -> inVal = false + } + XmlPullParser.TEXT -> if (inVal) cellBuf.append(parser.text) + } + event = parser.next() + if (rows.size > 5000) break + } + return rows + } + + // ── PPTX ─────────────────────────────────────────────────────────────────── + + private fun parsePptx(bytes: ByteArray): List { + val result = mutableListOf() + // Collect slides keyed by their numeric index so they render in order β€” ZipInputStream + // yields entries in arbitrary order, which previously jumbled the slide sequence. + val slideXml = sortedMapOf() + ZipInputStream(bytes.inputStream()).use { zip -> while (true) { val entry = zip.nextEntry ?: break - if (entry.isDirectory || !isRelevantOfficeEntry(entry.name, fileName)) continue - runCatching { - val parser = Xml.newPullParser().apply { - setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - setInput(zip, "UTF-8") - } - var event = parser.eventType - while (event != XmlPullParser.END_DOCUMENT) { - if (event == XmlPullParser.TEXT) { - parser.text.trim().takeIf { it.isNotEmpty() }?.let { lines += it } + val n = entry.name + if (n.startsWith("ppt/slides/slide") && n.endsWith(".xml")) { + val num = Regex("slide(\\d+)\\.xml").find(n)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: Int.MAX_VALUE + slideXml[num] = zip.readBytes() + } + } + } + var slideNum = 0 + for ((_, xml) in slideXml) { + slideNum++ + result.addAll(parseSlideXml(xml.inputStream(), slideNum)) + } + return result.ifEmpty { listOf(DocBlock.Para(SpannableStringBuilder("(empty presentation)"))) } + } + + private fun parseSlideXml(stream: InputStream, slideNum: Int): List { + val result = mutableListOf() + val header = SpannableStringBuilder("Slide $slideNum") + header.setSpan(StyleSpan(Typeface.BOLD), 0, header.length, 0) + result.add(DocBlock.Para(header, headingLevel = 2, spaceAfter = 4f)) + + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(stream, "UTF-8") + } + var inT = false + val buf = StringBuilder() + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> if (parser.name == "a:t") { inT = true; buf.clear() } + XmlPullParser.END_TAG -> if (parser.name == "a:t") { + val text = buf.toString().trim() + if (text.isNotEmpty()) result.add(DocBlock.Para(SpannableStringBuilder(text), spaceAfter = 4f)) + inT = false + } + XmlPullParser.TEXT -> if (inT) buf.append(parser.text) + } + event = parser.next() + } + return result + } + + // ── ODT ──────────────────────────────────────────────────────────────────── + + private fun parseOdt(bytes: ByteArray): List { + val result = mutableListOf() + ZipInputStream(bytes.inputStream()).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (entry.name == "content.xml") { + result.addAll(parseOdtContent(zip)) + break + } + zip.closeEntry() + } + } + return result.ifEmpty { listOf(DocBlock.Para(SpannableStringBuilder("(empty document)"))) } + } + + private fun parseOdtContent(stream: InputStream): List { + val result = mutableListOf() + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(stream, "UTF-8") + } + var inPara = false + val buf = StringBuilder() + var styleName = "" + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + when (event) { + XmlPullParser.START_TAG -> when (parser.name) { + "text:p", "text:h" -> { + inPara = true; buf.clear() + styleName = parser.getAttributeValue(null, "text:style-name") ?: "" + } + } + XmlPullParser.END_TAG -> when (parser.name) { + "text:p", "text:h" -> { + val text = buf.toString().trim() + if (text.isNotEmpty()) { + val heading = styleName.lowercase().contains("heading") + val ssb = SpannableStringBuilder(text) + if (heading) ssb.setSpan(StyleSpan(Typeface.BOLD), 0, ssb.length, 0) + result.add(DocBlock.Para(ssb, headingLevel = if (heading) 1 else 0)) } - event = parser.next() + inPara = false } } - zip.closeEntry() - if (lines.size >= 2000) break + XmlPullParser.TEXT -> if (inPara) buf.append(parser.text) } + event = parser.next() } - return lines.take(2000) + return result } - private fun isRelevantOfficeEntry(entryName: String, fileName: String): Boolean { - return when { - fileName.endsWith(".docx") -> entryName.startsWith("word/") && entryName.endsWith(".xml") && - !entryName.contains("styles") && !entryName.contains("settings") && !entryName.contains("rels") - fileName.endsWith(".pptx") -> entryName.startsWith("ppt/slides/") && entryName.endsWith(".xml") - fileName.endsWith(".xlsx") -> (entryName == "xl/sharedStrings.xml" || entryName.startsWith("xl/worksheets/")) && entryName.endsWith(".xml") - else -> entryName.endsWith(".xml") - } - } - - private fun extractLegacyOfficeText(input: java.io.InputStream, fileName: String): List { - return try { - when { - fileName.endsWith(".doc") -> WordExtractor(input).use { it.paragraphText.toList() } - fileName.endsWith(".xls") -> HSSFWorkbook(input).use { workbook -> - workbook.iterator().asSequence().flatMap { sheet -> - sheet.iterator().asSequence().map { row -> - row.iterator().asSequence().joinToString("\t") { cell -> cell.toString() } + // ── Legacy POI formats ───────────────────────────────────────────────────── + + private fun convertLegacyWordToPdf(context: Context, uri: Uri): Uri { + val paras = context.contentResolver.openInputStream(uri)?.use { stream -> + runCatching { WordExtractor(stream).use { it.paragraphText.toList() } }.getOrDefault(emptyList()) + } ?: emptyList() + val paint = bodyPaint() + val blocks = paras.filter { it.isNotBlank() }.map { DocBlock.Para(SpannableStringBuilder(it.trim())) } + return writePdf(context, renderBlocks(blocks, paint), "Doc") + } + + private fun convertLegacyXlsToPdf(context: Context, uri: Uri): Uri { + val blocks = context.contentResolver.openInputStream(uri)?.use { stream -> + runCatching { + HSSFWorkbook(stream).use { wb -> + // Every sheet, and address cells by COLUMN INDEX (getCell) so blank cells keep + // the columns aligned instead of collapsing (row.map skips missing cells). + (0 until wb.numberOfSheets).flatMap { si -> + val sheet = wb.getSheetAt(si) + val rows = sheet.mapNotNull { row -> + val lastCol = row.lastCellNum.toInt() + if (lastCol <= 0) null + else (0 until lastCol).map { c -> row.getCell(c)?.toString()?.trim() ?: "" } + } + if (rows.isEmpty()) emptyList() + else { + val header = SpannableStringBuilder(wb.getSheetName(si) ?: "Sheet ${si + 1}") + header.setSpan(StyleSpan(Typeface.BOLD), 0, header.length, 0) + listOf(DocBlock.Para(header, headingLevel = 1, spaceAfter = 6f), DocBlock.Table(rows)) } - }.toList() + } } - fileName.endsWith(".ppt") -> HSLFSlideShow(input).use { slideshow -> - slideshow.slides.flatMap { slide -> + }.getOrDefault(emptyList()) + } ?: emptyList() + val paint = bodyPaint() + return writePdf(context, renderBlocks(blocks.ifEmpty { listOf(DocBlock.Para(SpannableStringBuilder("(empty)"))) }, paint), "Xls") + } + + private fun convertLegacyPptToPdf(context: Context, uri: Uri): Uri { + val texts = context.contentResolver.openInputStream(uri)?.use { stream -> + runCatching { + HSLFSlideShow(stream).use { ss -> + ss.slides.flatMap { slide -> slide.shapes.mapNotNull { shape -> - (shape as? org.apache.poi.sl.usermodel.TextShape<*, *>)?.text + (shape as? org.apache.poi.sl.usermodel.TextShape<*, *>)?.text?.trim() + }.filter { it.isNotBlank() } + } + } + }.getOrDefault(emptyList()) + } ?: emptyList() + val paint = bodyPaint() + val blocks = texts.map { DocBlock.Para(SpannableStringBuilder(it)) } + return writePdf(context, renderBlocks(blocks.ifEmpty { listOf(DocBlock.Para(SpannableStringBuilder("(empty)"))) }, paint), "Ppt") + } + + // ── Rendering engine ─────────────────────────────────────────────────────── + + private sealed class DocBlock { + data class Para( + val text: SpannableStringBuilder, + val headingLevel: Int = 0, + val spaceAfter: Float = 8f, + val spaceBefore: Float = 0f, + val lineSpacingMult: Float = 1.25f, + val alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, + // Left indent in points (from w:ind) and whether the paragraph is justified (w:jc="both"). + // These are what let indented / justified Word text keep its shape instead of collapsing + // flush-left. + val leftIndent: Float = 0f, + val justify: Boolean = false + ) : DocBlock() + data class Table(val rows: List>) : DocBlock() + data class ImageBlock(val bitmap: android.graphics.Bitmap) : DocBlock() + } + + private fun renderBlocks(blocks: List, defaultPaint: TextPaint): PdfDocument { + val pdfDoc = PdfDocument() + var pageNum = 1 + var y = MARGIN + var page = pdfDoc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageNum).create()) + var canvas = page.canvas + + fun newPage() { + pdfDoc.finishPage(page) + pageNum++ + page = pdfDoc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageNum).create()) + canvas = page.canvas + y = MARGIN + } + + fun ensureRoom(needed: Float) { + if (y + needed > PAGE_H - MARGIN && y > MARGIN) newPage() + } + + for (block in blocks) { + when (block) { + is DocBlock.Para -> { + // An empty paragraph is a blank line the author put there on purpose β€” keep it as + // one line of vertical space instead of dropping it, so the document's spacing + // survives the conversion. + if (block.text.isEmpty()) { y += 13f; continue } + y += block.spaceBefore + val paint = if (block.headingLevel > 0) headingPaint(block.headingLevel) else defaultPaint + val indent = block.leftIndent.coerceIn(0f, TEXT_W - 40f) + val width = (TEXT_W - indent).toInt().coerceAtLeast(40) + val builder = StaticLayout.Builder + .obtain(block.text, 0, block.text.length, paint, width) + .setAlignment(block.alignment) + .setLineSpacing(2f, block.lineSpacingMult) + .setIncludePad(false) + // Justified text (Word's "both") β€” only left-aligned runs can be justified. + if (block.justify && block.alignment == Layout.Alignment.ALIGN_NORMAL && + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O + ) { + builder.setJustificationMode(Layout.JUSTIFICATION_MODE_INTER_WORD) + } + val layout = builder.build() + ensureRoom(layout.height.toFloat()) + canvas.save(); canvas.translate(MARGIN + indent, y); layout.draw(canvas); canvas.restore() + y += layout.height + block.spaceAfter + } + is DocBlock.ImageBlock -> { + val bmp = block.bitmap + if (bmp.isRecycled || bmp.width <= 0 || bmp.height <= 0) continue + // Fit to the text column width (never upscaled), and cap to the page height. + var drawW = TEXT_W.toFloat().coerceAtMost(bmp.width.toFloat()) + var drawH = drawW * bmp.height / bmp.width + val maxH = PAGE_H - 2 * MARGIN + if (drawH > maxH) { drawH = maxH; drawW = drawH * bmp.width / bmp.height } + ensureRoom(drawH) + val left = MARGIN + (TEXT_W - drawW) / 2f // centre images like Word usually does + val dst = android.graphics.RectF(left, y, left + drawW, y + drawH) + canvas.drawBitmap(bmp, null, dst, Paint(Paint.FILTER_BITMAP_FLAG)) + y += drawH + 10f + bmp.recycle() + } + is DocBlock.Table -> { + if (block.rows.isEmpty()) continue + // Up to 16 columns (was 8, which silently dropped wider sheets). Font size and + // the per-cell character budget scale to the actual column width, so text no + // longer overflows into the next column and long values are trimmed to fit β€” not + // to a fixed 24 chars that spilled over narrow columns. + val maxCols = block.rows.maxOf { it.size }.coerceIn(1, 16) + val colW = TEXT_W.toFloat() / maxCols + val cellPaint = cellPaint().apply { + textSize = when { maxCols > 10 -> 7f; maxCols > 6 -> 8f; else -> 9f } + } + val charW = cellPaint.measureText("0").coerceAtLeast(1f) + val maxChars = ((colW - 6f) / charW).toInt().coerceAtLeast(2) + val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(80, 0, 0, 0); strokeWidth = 0.5f + } + val rowH = 16f + + for (row in block.rows.take(2000)) { + ensureRoom(rowH + 2f) + // horizontal top rule + canvas.drawLine(MARGIN, y, MARGIN + TEXT_W, y, linePaint) + row.take(maxCols).forEachIndexed { colIdx, cell -> + val x = MARGIN + colIdx * colW + val clipped = if (cell.length > maxChars) cell.take((maxChars - 1).coerceAtLeast(1)) + "…" else cell + canvas.drawText(clipped, x + 3f, y + rowH - 5f, cellPaint) + // vertical divider + if (colIdx > 0) canvas.drawLine(x, y, x, y + rowH, linePaint) } + y += rowH } + // bottom rule + spacing + canvas.drawLine(MARGIN, y, MARGIN + TEXT_W, y, linePaint) + y += 14f } - else -> emptyList() - }.filter { it.isNotBlank() }.take(2000) - } catch (_: Throwable) { - // Some malformed legacy files are still better served by the readable-byte fallback. - emptyList() + } } + pdfDoc.finishPage(page) + return pdfDoc } - private fun getFileName(context: Context, uri: Uri): String { - return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - if (nameIndex != -1 && cursor.moveToFirst()) cursor.getString(nameIndex) else null - } ?: uri.lastPathSegment ?: "document" + // ── Paint helpers ────────────────────────────────────────────────────────── + + private fun bodyPaint(typeface: Typeface = Typeface.SERIF, sizeSp: Float = 11f) = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = sizeSp + color = Color.BLACK + this.typeface = typeface + } + + private fun headingPaint(level: Int) = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = when (level) { 1 -> 18f; 2 -> 15f; 3 -> 13f; else -> 12f } + color = Color.BLACK + typeface = Typeface.create(Typeface.SERIF, Typeface.BOLD) + } + + private fun cellPaint() = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = 9f + color = Color.DKGRAY + typeface = Typeface.MONOSPACE } - private fun createTempPdfFile(context: Context, prefix: String): File { - val dir = File(context.cacheDir, "converted_pdfs") - if (!dir.exists()) dir.mkdirs() - return File(dir, "${prefix}_${System.currentTimeMillis()}.pdf") + // ── Output helpers ───────────────────────────────────────────────────────── + + private fun writePdf(context: Context, pdfDoc: PdfDocument, prefix: String): Uri { + val dir = File(context.cacheDir, "converted_pdfs").also { it.mkdirs() } + val file = File(dir, "${prefix}_${System.currentTimeMillis()}.pdf") + FileOutputStream(file).use { pdfDoc.writeTo(it) } + pdfDoc.close() + return android.net.Uri.fromFile(file) } + + private fun getFileName(context: Context, uri: Uri): String = + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val col = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (col != -1 && cursor.moveToFirst()) cursor.getString(col) else null + } ?: uri.lastPathSegment ?: "document" + + private fun String.endsWithAny(vararg suffixes: String) = suffixes.any { this.endsWith(it, ignoreCase = true) } } diff --git a/app/src/main/res/drawable/ic_pinned_badge.xml b/app/src/main/res/drawable/ic_pinned_badge.xml new file mode 100644 index 0000000..d393b05 --- /dev/null +++ b/app/src/main/res/drawable/ic_pinned_badge.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1539f0b..75e35a6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -18,6 +18,8 @@ Escolha seu idioma Personalize sua experiência Idioma + Deixe do seu jeito + Escolha um tema e um plano de fundo. Mude quando quiser nas Configurações. Abra qualquer arquivo PDFs, documentos, planilhas, apresentações, imagens e muito mais Ferramentas poderosas @@ -28,6 +30,9 @@ Continuar Começar %1$d de %2$d + Pular + Ver introdução novamente + Repetir o tour de boas-vindas ClearPDF @@ -43,9 +48,19 @@ Abra um PDF para começar NO DISPOSITIVO + + Pesquisar + Limpar pesquisa + Ferramentas Tudo que você precisa para trabalhar com PDFs. + Pesquisar ferramentas + Nenhuma ferramenta encontrada + Organizar + Converter + Editar + Otimizar e proteger Abrir PDF Visualizar e ler Mesclar PDFs @@ -74,14 +89,12 @@ Sempre usar tema escuro Local de Salvamento Downloads - Pasta Personalizada + Personalizada Gerenciamento de Arquivos Comprimir Automaticamente Comprimir PDFs automaticamente ao importar Manter Original Preservar arquivo original após a edição - Notificações - Mostrar notificação quando tarefas concluírem Qualidade de compressão Menor tamanho Maior qualidade @@ -113,6 +126,14 @@ Anterior Próximo Abrir outro PDF + Compartilhar documento + Ferramentas + Ferramentas de edição em breve + Abrir PDF + Mesclar Agora + Mesclando… + Excluir assinatura? + “%1$s” será removida permanentemente. Caneta Destaque Retângulo @@ -125,6 +146,8 @@ Sublinhado Tachado Concluído + Cor + Editar forma Nova Assinatura Substituir Salvando PDF editado… @@ -160,6 +183,7 @@ Cancelar OK Copiar + Editar Compartilhar Excluir Abrir @@ -170,6 +194,9 @@ Diretório personalizado Diretório padrão Downloads / ClearPDF + Alterar pasta + Redefinir + Galeria Licenciado sob a Apache License, Versão 2.0.\nVocê pode obter uma cópia em apache.org/licenses/LICENSE-2.0 por %1$s Inglês @@ -181,6 +208,33 @@ Compartilhar PDF PDF Pressione e segure para ações rápidas + Pesquisar recentes + Nenhum arquivo correspondente + Planilha anterior + Próxima planilha + %1$d de %2$d + Valor da célula + Célula vazia + Planilha %1$d de %2$d + Planilha %1$d / %2$d + PLANILHAS + Exportar PDF + Ampliar + Reduzir + Editar imagem + Girar à esquerda + Girar à direita + Redefinir + Brilho + Contraste + Salvar na galeria + Exportar PDF + Original + Mono + Sépia + VΓ­vido + Frio + Quente Informações do arquivo Páginas Adicionado @@ -195,6 +249,18 @@ %1$d B %1$d páginas Tamanho + %1$d planilhas + Planilhas + Fixar + Desafixar + Filtrar por tipo + Tudo + PDF + Word + Excel + Slides + Imagens + Deslize para remover Descriptografar PDF Remover a senha de um PDF Criptografar PDF @@ -231,6 +297,16 @@ Página Ir OCR (%1$d) + + + Compartilhar + Formato + Criptografar com senha + Senha + Compartilhar + PDF + Normal + Criptografado Selecione dois ou mais PDFs para mesclar em um Escolha um modo e execute uma ação principal clara. %1$d arquivos selecionados @@ -358,4 +434,72 @@ Imagens -> PDF O scanner estΓ‘ indisponΓ­vel neste contexto. NΓ£o foi possΓ­vel iniciar o scanner. + Marca d\'Γ‘gua + Carimbe texto nas pΓ‘ginas + Marca d\'Γ‘gua + Texto + Imagem + Escolher imagem + ex.: CONFIDENCIAL + Opacidade + Diagonal (45Β°) + Aplicar marca d\'Γ‘gua + Aplicando… + PersonalizaΓ§Γ£o + Plano de fundo + Mostrar a imagem de fundo atrΓ‘s do app + PolΓ­tica de Privacidade + VersΓ£o + Extrair pΓ‘ginas + Extrair pΓ‘ginas p/ novo PDF + PΓ‘ginas a extrair + ex.: 1-3, 5, 8-10 + de %1$d + Extrair pΓ‘ginas + Extraindo… + Numerar pΓ‘ginas + Numerar todas as pΓ‘ginas + PosiΓ§Γ£o + Centro + Direita + Mostrar total + Exibir como \"3 / 12\" em vez de \"3\" + Numerar pΓ‘ginas + Numerando… + Achatar PDF + Tornar formulΓ‘rios fixos + Fixa os campos de formulΓ‘rio no conteΓΊdo da pΓ‘gina para que nΓ£o possam mais ser editados. + Os valores do formulΓ‘rio viram conteΓΊdo permanente. + Achatar PDF + Achatando… + Ferramentas de imagem + Comprimir, redimensionar + Escolher imagem + OpΓ§Γ΅es + Formato + Qualidade + Redimensionar + Os metadados (EXIF/GPS) sΓ£o removidos na exportaΓ§Γ£o. + Processar imagem + Processando… + Salvar + Compartilhar + Web para PDF + URL ou HTML em PDF + URL da web + HTML + Informe um endereΓ§o da web para capturar a pΓ‘gina como PDF. + https://exemplo.com + Cole HTML ou carregue um arquivo .html β€” renderizado no dispositivo. + Carregar arquivo .html + <h1>OlΓ‘</h1><p>Seu HTML aqui…</p> + Converter em PDF + Renderizando… + Preencher formulΓ‘rio + Preencher campos de formulΓ‘rio + Campos do formulΓ‘rio + Achatar ao salvar + Tornar os valores preenchidos permanentes + Salvar PDF preenchido + Salvando… diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 87fcda8..7fc6850 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -18,6 +18,8 @@ Escolha seu idioma Personalize sua experiência Idioma + Deixe do seu jeito + Escolha um tema e um plano de fundo. Mude quando quiser nas Configurações. Abra qualquer arquivo PDFs, documentos, planilhas, apresentações, imagens e muito mais Ferramentas poderosas @@ -28,6 +30,9 @@ Continuar Começar %1$d de %2$d + Pular + Ver introdução novamente + Repetir o tour de boas-vindas ClearPDF @@ -43,9 +48,19 @@ Abra um PDF para começar NO DISPOSITIVO + + Pesquisar + Limpar pesquisa + Ferramentas Tudo que você precisa para trabalhar com PDFs. + Pesquisar ferramentas + Nenhuma ferramenta encontrada + Organizar + Converter + Editar + Otimizar e proteger Abrir PDF Visualizar e ler Mesclar PDFs @@ -74,14 +89,12 @@ Sempre usar tema escuro Local de Salvamento Downloads - Pasta Personalizada + Personalizada Gerenciamento de Arquivos Comprimir Automaticamente Comprimir PDFs automaticamente ao importar Manter Original Preservar arquivo original após a edição - Notificações - Mostrar notificação quando tarefas concluírem Qualidade de compressão Menor tamanho Maior qualidade @@ -113,6 +126,14 @@ Anterior Próximo Abrir outro PDF + Compartilhar documento + Ferramentas + Ferramentas de edição em breve + Abrir PDF + Mesclar Agora + Mesclando… + Excluir assinatura? + “%1$s” será removida permanentemente. Caneta Destaque Retângulo @@ -125,6 +146,8 @@ Sublinhado Tachado Concluído + Cor + Editar forma Nova Assinatura Substituir Salvando PDF editado… @@ -160,6 +183,7 @@ Cancelar OK Copiar + Editar Compartilhar Excluir Abrir @@ -170,6 +194,9 @@ Diretório personalizado Diretório padrão Downloads / ClearPDF + Alterar pasta + Redefinir + Galeria Licenciado sob a Apache License, Versão 2.0.\nVocê pode obter uma cópia em apache.org/licenses/LICENSE-2.0 por %1$s Inglês @@ -181,6 +208,33 @@ Compartilhar PDF PDF Pressione e segure para ações rápidas + Pesquisar recentes + Nenhum arquivo correspondente + Planilha anterior + Próxima planilha + %1$d de %2$d + Valor da célula + Célula vazia + Planilha %1$d de %2$d + Planilha %1$d / %2$d + PLANILHAS + Exportar PDF + Ampliar + Reduzir + Editar imagem + Girar à esquerda + Girar à direita + Redefinir + Brilho + Contraste + Salvar na galeria + Exportar PDF + Original + Mono + Sépia + VΓ­vido + Frio + Quente Informações do arquivo Páginas Adicionado @@ -195,6 +249,18 @@ %1$d B %1$d páginas Tamanho + %1$d planilhas + Planilhas + Fixar + Desafixar + Filtrar por tipo + Tudo + PDF + Word + Excel + Slides + Imagens + Deslize para remover Descriptografar PDF Remover a senha de um PDF Criptografar PDF @@ -231,6 +297,16 @@ Página Ir OCR (%1$d) + + + Compartilhar + Formato + Criptografar com senha + Senha + Compartilhar + PDF + Normal + Criptografado Selecione dois ou mais PDFs para mesclar em um Escolha um modo e execute uma ação principal clara. %1$d arquivos selecionados @@ -358,4 +434,72 @@ Imagens -> PDF O scanner estΓ‘ indisponΓ­vel neste contexto. NΓ£o foi possΓ­vel iniciar o scanner. + Marca d\'Γ‘gua + Carimbe texto nas pΓ‘ginas + Marca d\'Γ‘gua + Texto + Imagem + Escolher imagem + ex.: CONFIDENCIAL + Opacidade + Diagonal (45Β°) + Aplicar marca d\'Γ‘gua + Aplicando… + PersonalizaΓ§Γ£o + Plano de fundo + Mostrar a imagem de fundo atrΓ‘s do app + PolΓ­tica de Privacidade + VersΓ£o + Extrair pΓ‘ginas + Extrair pΓ‘ginas p/ novo PDF + PΓ‘ginas a extrair + ex.: 1-3, 5, 8-10 + de %1$d + Extrair pΓ‘ginas + Extraindo… + Numerar pΓ‘ginas + Numerar todas as pΓ‘ginas + PosiΓ§Γ£o + Centro + Direita + Mostrar total + Exibir como \"3 / 12\" em vez de \"3\" + Numerar pΓ‘ginas + Numerando… + Achatar PDF + Tornar formulΓ‘rios fixos + Fixa os campos de formulΓ‘rio no conteΓΊdo da pΓ‘gina para que nΓ£o possam mais ser editados. + Os valores do formulΓ‘rio viram conteΓΊdo permanente. + Achatar PDF + Achatando… + Ferramentas de imagem + Comprimir, redimensionar + Escolher imagem + OpΓ§Γ΅es + Formato + Qualidade + Redimensionar + Os metadados (EXIF/GPS) sΓ£o removidos na exportaΓ§Γ£o. + Processar imagem + Processando… + Salvar + Compartilhar + Web para PDF + URL ou HTML em PDF + URL da web + HTML + Informe um endereΓ§o da web para capturar a pΓ‘gina como PDF. + https://exemplo.com + Cole HTML ou carregue um arquivo .html β€” renderizado no dispositivo. + Carregar arquivo .html + <h1>OlΓ‘</h1><p>Seu HTML aqui…</p> + Converter em PDF + Renderizando… + Preencher formulΓ‘rio + Preencher campos de formulΓ‘rio + Campos do formulΓ‘rio + Achatar ao salvar + Tornar os valores preenchidos permanentes + Salvar PDF preenchido + Salvando… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cefdd4..e9dfd1d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ Choose your language Personalize your experience Language + Make it yours + Pick a theme and a background. Change both any time in Settings. Open anything PDFs, docs, sheets, slides, images & more Powerful tools @@ -25,6 +27,9 @@ Continue Get Started %1$d of %2$d + Skip + Watch intro again + Replay the welcome tour ClearPDF @@ -40,9 +45,19 @@ Open a PDF to get started ON-DEVICE + + Search + Clear search + Tools Everything you need to work with PDFs. + Search tools + No matching tools + Organize + Convert + Edit + Optimize & Secure Open PDF View and read Merge PDFs @@ -55,6 +70,82 @@ Reorder & rotate Images to PDF Photos to PDF + PDF to Images + Export pages as JPG/PNG + Watermark + Stamp text over pages + Watermark + Text + Image + Pick watermark image + e.g. CONFIDENTIAL + Opacity + Diagonal (45Β°) + Apply Watermark + Applying… + Personalization + Background + Show the wallpaper image behind the app + Privacy Policy + Version + Extract Pages + Pull pages into a new PDF + Pages to extract + e.g. 1-3, 5, 8-10 + of %1$d + Extract Pages + Extracting… + Page Numbers + Number every page + Position + Center + Right + Show total + Render as "3 / 12" instead of "3" + Add Page Numbers + Adding… + Flatten PDF + Make forms permanent + Bake interactive form fields into static content so they can no longer be edited. + Form values become permanent page content. + Flatten PDF + Flattening… + Image Tools + Compress, resize, convert + Pick Image + Options + Format + Quality + Resize + Metadata (EXIF/GPS) is removed on export. + Process Image + Processing… + Save + Share + Web to PDF + URL or HTML to PDF + Web URL + HTML + Enter a web address to capture the page as a PDF. + https://example.com + Paste HTML or load an .html file β€” rendered on-device. + Load .html file + <h1>Hello</h1><p>Your HTML here…</p> + Convert to PDF + Rendering… + Fill Form + Complete PDF form fields + Form fields + Flatten when saving + Make the filled values permanent + Save Filled PDF + Saving… + Render each page to a high-quality image you can save or share. + Image format + Convert to images + Converting… %1$d%% + Rendered pages + Save to gallery Extract Text Copy text Create PDF @@ -77,8 +168,6 @@ Automatically compress PDFs on import Keep Original Preserve original file after editing - Notifications - Show notification when tasks finish Compression Quality Smaller size Higher quality @@ -110,6 +199,10 @@ Previous Next Open Another PDF + Share Document + Editor Tools + Editor tools coming soon + Open PDF Pen Highlight Rect @@ -122,6 +215,8 @@ Underline Strike Done + Color + Edit Shape New Sign Replace Saving edited PDF… @@ -132,6 +227,16 @@ Go OCR (%1$d) + + Share + Format + Encrypt with password + Password + Share + PDF + Normal + Encrypted + Draw your signature Saved @@ -145,6 +250,9 @@ Saved Signatures Name this signature Save name + Name your signature + Delete signature? + β€œ%1$s” will be permanently removed. Annotate & Sign @@ -163,16 +271,24 @@ Cancel OK Copy + Edit Share Delete Open Save Dismiss + Sticky note + Insert text + Type here… + Save Not now Don\'t ask again Custom Directory Default Directory Downloads / ClearPDF + Change Folder + Reset + Gallery Licensed under the Apache License, Version 2.0.\nYou may obtain a copy at apache.org/licenses/LICENSE-2.0 by %1$s English @@ -184,6 +300,33 @@ Share PDF PDF Long press for quick actions + Search recents + No matching files + Previous sheet + Next sheet + %1$d of %2$d + Cell value + Empty cell + Sheet %1$d of %2$d + Sheet %1$d / %2$d + SHEETS + Export PDF + Zoom in + Zoom out + Edit Image + Rotate left + Rotate right + Reset + Brightness + Contrast + Save to gallery + Export PDF + Original + Mono + Sepia + Vivid + Cool + Warm File Info Pages Added @@ -198,6 +341,18 @@ %1$d B %1$d pages Size + %1$d sheets + Sheets + Pin + Unpin + Filter by type + All + PDF + Word + Excel + Slides + Images + Swipe left to remove Decrypt PDF Remove a PDF password Encrypt PDF @@ -237,6 +392,8 @@ Reorder files before merging. Top to bottom = output order. Select at least 2 PDFs to continue. Add Files + Merge Now + Merging… All Odd Even diff --git a/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt b/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt index d91d6ad..925b91f 100644 --- a/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt +++ b/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt @@ -76,7 +76,6 @@ fun Modifier.drawPlainBackdrop( ) ) } - fun Modifier.drawBackdrop( backdrop: Backdrop, shape: () -> Shape, diff --git a/demo/1.mp4 b/demo/1.mp4 new file mode 100644 index 0000000..8e89e63 Binary files /dev/null and b/demo/1.mp4 differ diff --git a/demo/2.mp4 b/demo/2.mp4 new file mode 100644 index 0000000..e14c582 Binary files /dev/null and b/demo/2.mp4 differ diff --git a/demo/3.jpg b/demo/3.jpg new file mode 100644 index 0000000..3f9dc27 Binary files /dev/null and b/demo/3.jpg differ diff --git a/demo/4.jpg b/demo/4.jpg new file mode 100644 index 0000000..ccc4ecb Binary files /dev/null and b/demo/4.jpg differ diff --git a/demo/5.jpg b/demo/5.jpg new file mode 100644 index 0000000..a73f1c4 Binary files /dev/null and b/demo/5.jpg differ diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/flatten/PdfFlattener.kt b/pdf-core/src/main/java/com/kyant/pdfcore/flatten/PdfFlattener.kt new file mode 100644 index 0000000..fb16d4b --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/flatten/PdfFlattener.kt @@ -0,0 +1,37 @@ +package com.kyant.pdfcore.flatten + +import android.content.Context +import android.net.Uri +import com.kyant.pdfcore.internal.PdfBox +import com.tom_roush.pdfbox.pdmodel.PDDocument + +/** + * Flattens a PDF's interactive AcroForm fields into static page content so the document can no + * longer be edited as a form (values become permanent). The source URI is never modified; the + * result is written to [destinationUri]. Runs on the caller's worker thread. + * + * Returns the number of form fields that were flattened (0 = the PDF had no form; a static copy + * is still written). + */ +object PdfFlattener { + + fun flatten(context: Context, sourceUri: Uri, destinationUri: Uri): Int { + PdfBox.ensureInitialized(context) + var fieldCount = 0 + context.contentResolver.openInputStream(sourceUri)?.use { input -> + PDDocument.load(input).use { doc -> + val acroForm = doc.documentCatalog?.acroForm + if (acroForm != null) { + fieldCount = acroForm.fields?.size ?: 0 + if (fieldCount > 0) { + runCatching { acroForm.flatten() } + } + } + context.contentResolver.openOutputStream(destinationUri)?.use { output -> + doc.save(output) + } ?: throw IllegalStateException("Unable to write PDF") + } + } ?: throw IllegalStateException("Unable to read PDF") + return fieldCount + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/form/PdfFormService.kt b/pdf-core/src/main/java/com/kyant/pdfcore/form/PdfFormService.kt new file mode 100644 index 0000000..9748a5e --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/form/PdfFormService.kt @@ -0,0 +1,101 @@ +package com.kyant.pdfcore.form + +import android.content.Context +import android.net.Uri +import com.kyant.pdfcore.internal.PdfBox +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.pdmodel.interactive.form.PDCheckBox +import com.tom_roush.pdfbox.pdmodel.interactive.form.PDChoice +import com.tom_roush.pdfbox.pdmodel.interactive.form.PDField +import com.tom_roush.pdfbox.pdmodel.interactive.form.PDNonTerminalField +import com.tom_roush.pdfbox.pdmodel.interactive.form.PDTextField + +/** + * Reads and fills interactive AcroForm fields via PDFBox. Handles the common terminal field + * types (text, checkbox, choice); non-terminal container fields are skipped. The source URI is + * never modified β€” filling writes to a destination URI. Runs on the caller's worker thread. + */ +object PdfFormService { + + enum class FieldType { TEXT, CHECKBOX, CHOICE } + + data class FormField( + val name: String, + val type: FieldType, + val value: String, + val options: List = emptyList() + ) + + /** Enumerate the editable terminal fields of a PDF's form (empty if it has none). */ + fun readFields(context: Context, uri: Uri): List { + PdfBox.ensureInitialized(context) + val fields = mutableListOf() + context.contentResolver.openInputStream(uri)?.use { input -> + PDDocument.load(input).use { doc -> + val acro = doc.documentCatalog?.acroForm ?: return emptyList() + acro.fields?.forEach { collect(it, fields) } + } + } + return fields + } + + private fun collect(field: PDField, out: MutableList) { + when (field) { + is PDNonTerminalField -> field.children?.forEach { collect(it, out) } + is PDTextField -> out.add(FormField(field.fullyQualifiedName, FieldType.TEXT, field.valueAsString ?: "")) + is PDCheckBox -> out.add(FormField(field.fullyQualifiedName, FieldType.CHECKBOX, if (field.isChecked) "true" else "false")) + is PDChoice -> out.add( + FormField( + field.fullyQualifiedName, FieldType.CHOICE, field.valueAsString ?: "", + runCatching { field.options ?: emptyList() }.getOrDefault(emptyList()) + ) + ) + else -> { /* buttons / signatures / unsupported β€” skip */ } + } + } + + /** + * Apply [values] (keyed by fully-qualified field name) and write to [destinationUri]. + * Returns the number of fields written. When [flatten] is true the filled fields are baked + * into static page content so they can no longer be edited. + */ + fun fill( + context: Context, + sourceUri: Uri, + destinationUri: Uri, + values: Map, + flatten: Boolean + ): Int { + PdfBox.ensureInitialized(context) + var written = 0 + context.contentResolver.openInputStream(sourceUri)?.use { input -> + PDDocument.load(input).use { doc -> + val acro = doc.documentCatalog?.acroForm ?: throw IllegalStateException("This PDF has no form fields") + acro.fields?.forEach { written += applyValue(it, values) } + if (flatten && written > 0) runCatching { acro.flatten() } + context.contentResolver.openOutputStream(destinationUri)?.use { output -> + doc.save(output) + } ?: throw IllegalStateException("Unable to write PDF") + } + } ?: throw IllegalStateException("Unable to read PDF") + return written + } + + private fun applyValue(field: PDField, values: Map): Int { + var count = 0 + when (field) { + is PDNonTerminalField -> field.children?.forEach { count += applyValue(it, values) } + is PDTextField -> values[field.fullyQualifiedName]?.let { + runCatching { field.setValue(it) }.onSuccess { count++ } + } + is PDCheckBox -> values[field.fullyQualifiedName]?.let { + runCatching { if (it == "true") field.check() else field.unCheck() }.onSuccess { count++ } + } + is PDChoice -> values[field.fullyQualifiedName]?.let { + runCatching { field.setValue(it) }.onSuccess { count++ } + } + else -> { /* skip */ } + } + return count + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/image/ImageProcessor.kt b/pdf-core/src/main/java/com/kyant/pdfcore/image/ImageProcessor.kt new file mode 100644 index 0000000..6a6dbc3 --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/image/ImageProcessor.kt @@ -0,0 +1,115 @@ +package com.kyant.pdfcore.image + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import com.kyant.pdfcore.raster.PdfRasterizer.ImageFormat +import java.io.File +import java.io.FileOutputStream + +/** + * On-device image processing: resize, re-encode (JPG/PNG/WebP) and compress. Re-encoding a + * decoded [Bitmap] inherently drops all source metadata (EXIF/GPS), so the output is stripped + * of tracking data by construction. Nothing leaves the device. + */ +object ImageProcessor { + + data class Result(val uri: Uri, val file: File, val width: Int, val height: Int, val sizeBytes: Long) + data class SourceInfo(val width: Int, val height: Int, val sizeBytes: Long) + + /** Read dimensions + byte size without decoding the full bitmap. */ + fun inspect(context: Context, source: Uri): SourceInfo { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, opts) } + val size = context.contentResolver.openFileDescriptor(source, "r")?.use { it.statSize } ?: -1L + return SourceInfo(opts.outWidth.coerceAtLeast(0), opts.outHeight.coerceAtLeast(0), size) + } + + /** + * @param format output encoding. + * @param quality 0..100 (ignored for lossless PNG). + * @param scalePercent 10..100 of the source dimensions. + */ + fun process( + context: Context, + source: Uri, + format: ImageFormat, + quality: Int, + scalePercent: Int + ): Result { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, bounds) } + ?: throw IllegalStateException("Unable to read image") + val srcW = bounds.outWidth + val srcH = bounds.outHeight + if (srcW <= 0 || srcH <= 0) throw IllegalStateException("Unsupported image") + + val scale = scalePercent.coerceIn(10, 100) / 100f + val targetW = (srcW * scale).toInt().coerceAtLeast(1) + val targetH = (srcH * scale).toInt().coerceAtLeast(1) + + // Downsample while decoding to keep memory bounded for large photos. + var sample = 1 + while (srcW / (sample * 2) >= targetW && srcH / (sample * 2) >= targetH) sample *= 2 + val decodeOpts = BitmapFactory.Options().apply { inSampleSize = sample } + val decoded = context.contentResolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, decodeOpts) } + ?: throw IllegalStateException("Unable to decode image") + + val scaled = if (decoded.width != targetW || decoded.height != targetH) { + Bitmap.createScaledBitmap(decoded, targetW, targetH, true).also { if (it != decoded) decoded.recycle() } + } else decoded + + // JPEG has no alpha channel; flatten transparency onto white so it doesn't render black. + val output = if (format == ImageFormat.JPEG && scaled.hasAlpha()) { + Bitmap.createBitmap(scaled.width, scaled.height, Bitmap.Config.ARGB_8888).also { bmp -> + Canvas(bmp).apply { drawColor(Color.WHITE); drawBitmap(scaled, 0f, 0f, null) } + scaled.recycle() + } + } else scaled + + val runDir = File(File(context.cacheDir, "image_tools"), "run_${System.currentTimeMillis()}").apply { mkdirs() } + val file = File(runDir, "image_${System.currentTimeMillis()}.${format.extension}") + FileOutputStream(file).use { out -> + val cf = when (format) { + ImageFormat.JPEG -> Bitmap.CompressFormat.JPEG + ImageFormat.PNG -> Bitmap.CompressFormat.PNG + ImageFormat.WEBP -> Bitmap.CompressFormat.WEBP + } + output.compress(cf, quality.coerceIn(0, 100), out) + } + val w = output.width + val h = output.height + output.recycle() + + val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + return Result(uri, file, w, h, file.length()) + } + + /** Save a processed file into the shared Pictures/[albumName] collection. */ + fun saveToGallery(context: Context, file: File, format: ImageFormat, albumName: String = "ClearPDF"): Boolean { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, file.name) + put(MediaStore.Images.Media.MIME_TYPE, format.mime) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/$albumName") + } + } + val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val item = resolver.insert(collection, values) ?: return false + return runCatching { + resolver.openOutputStream(item)?.use { out -> file.inputStream().use { it.copyTo(out) } } + true + }.getOrDefault(false) + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/internal/PdfBox.kt b/pdf-core/src/main/java/com/kyant/pdfcore/internal/PdfBox.kt index 48a9365..c4a88d6 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/internal/PdfBox.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/internal/PdfBox.kt @@ -10,7 +10,7 @@ import com.tom_roush.pdfbox.android.PDFBoxResourceLoader * is loaded (it resolves bundled fonts/CMaps through assets). Call [ensureInitialized] * at the top of every entry point that touches PdfBox. */ -internal object PdfBox { +object PdfBox { @Volatile private var initialized = false diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/pagenumber/PdfPageNumberer.kt b/pdf-core/src/main/java/com/kyant/pdfcore/pagenumber/PdfPageNumberer.kt new file mode 100644 index 0000000..047467f --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/pagenumber/PdfPageNumberer.kt @@ -0,0 +1,75 @@ +package com.kyant.pdfcore.pagenumber + +import android.content.Context +import android.graphics.Color +import android.net.Uri +import com.kyant.pdfcore.internal.PdfBox +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.pdmodel.PDPageContentStream +import com.tom_roush.pdfbox.pdmodel.font.PDType1Font +import kotlin.math.min + +/** + * Stamps a page number onto the bottom margin of every page via PDFBox. The source URI is + * never modified; the result is written to [destinationUri]. Runs on the caller's worker + * thread (same contract as the other pdf-core services). Original ClearPDF code, built on + * the same text-stamp path as PdfWatermarker. + */ +object PdfPageNumberer { + + enum class Position { CENTER, RIGHT } + + /** + * @param position bottom-center or bottom-right. + * @param includeTotal true renders "3 / 12"; false renders "3". + * @param startAt the number printed on the first page (default 1). + */ + fun apply( + context: Context, + sourceUri: Uri, + destinationUri: Uri, + position: Position, + includeTotal: Boolean, + startAt: Int = 1, + colorArgb: Int = 0xFF444444.toInt() + ) { + PdfBox.ensureInitialized(context) + val c = Color.valueOf(colorArgb) + val font = PDType1Font.HELVETICA + + context.contentResolver.openInputStream(sourceUri)?.use { input -> + PDDocument.load(input).use { doc -> + val total = doc.numberOfPages + for (i in 0 until total) { + val page = doc.getPage(i) + val box = page.cropBox ?: page.mediaBox ?: continue + val w = box.width + val h = box.height + val originX = box.lowerLeftX + val originY = box.lowerLeftY + val fontSize = (min(w, h) * 0.018f).coerceIn(9f, 14f) + val margin = fontSize * 2.2f + val text = if (includeTotal) "${startAt + i} / ${startAt + total - 1}" else "${startAt + i}" + val textWidth = font.getStringWidth(text) / 1000f * fontSize + val x = when (position) { + Position.CENTER -> originX + w / 2f - textWidth / 2f + Position.RIGHT -> originX + w - margin - textWidth + } + val y = originY + margin - fontSize / 2f + + PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> + cs.beginText() + cs.setNonStrokingColor(c.red(), c.green(), c.blue()) + cs.setFont(font, fontSize) + cs.newLineAtOffset(x, y) + runCatching { cs.showText(text) } + cs.endText() + } + } + context.contentResolver.openOutputStream(destinationUri)?.use { output -> + doc.save(output) + } ?: throw IllegalStateException("Unable to write PDF") + } + } ?: throw IllegalStateException("Unable to read PDF") + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/raster/PdfRasterizer.kt b/pdf-core/src/main/java/com/kyant/pdfcore/raster/PdfRasterizer.kt new file mode 100644 index 0000000..67c50cd --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/raster/PdfRasterizer.kt @@ -0,0 +1,119 @@ +package com.kyant.pdfcore.raster + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.core.content.FileProvider +import java.io.File +import java.io.FileOutputStream + +/** + * Renders PDF pages to raster image files (JPEG / PNG / WebP) fully on-device using + * the platform [PdfRenderer]. Output is written to the app cache and exposed through + * a [FileProvider] content Uri so it can be shared or opened. + */ +object PdfRasterizer { + + enum class ImageFormat(val extension: String, val mime: String) { + JPEG("jpg", "image/jpeg"), + PNG("png", "image/png"), + WEBP("webp", "image/webp") + } + + data class RasterPage(val pageIndex: Int, val uri: Uri, val file: File) + + /** + * Render every page of [source] to an image. + * + * @param dpi target render density; 150 is a good screen/print compromise. + * @param quality 0..100 (ignored for lossless PNG). + * @param onProgress invoked as pages complete: (done, total). + */ + fun rasterize( + context: Context, + source: Uri, + format: ImageFormat, + dpi: Int = 150, + quality: Int = 90, + onProgress: ((done: Int, total: Int) -> Unit)? = null + ): List { + val outDir = File(context.cacheDir, "pdf_images").apply { mkdirs() } + // A fresh sub-folder per run keeps exports from previous runs from piling up in shares. + val runDir = File(outDir, "run_${System.currentTimeMillis()}").apply { mkdirs() } + val results = mutableListOf() + + val pfd = context.contentResolver.openFileDescriptor(source, "r") + ?: throw IllegalStateException("Cannot open PDF") + + pfd.use { descriptor -> + PdfRenderer(descriptor).use { renderer -> + val total = renderer.pageCount + val scale = dpi / 72f + for (i in 0 until total) { + renderer.openPage(i).use { page -> + val w = (page.width * scale).toInt().coerceAtLeast(1) + val h = (page.height * scale).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + // Paint a white backing so transparent PDF areas don't render black in JPEG. + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + + val file = File(runDir, "page_${i + 1}.${format.extension}") + FileOutputStream(file).use { out -> + val compressFormat = when (format) { + ImageFormat.JPEG -> Bitmap.CompressFormat.JPEG + ImageFormat.PNG -> Bitmap.CompressFormat.PNG + ImageFormat.WEBP -> Bitmap.CompressFormat.WEBP + } + bitmap.compress(compressFormat, quality.coerceIn(0, 100), out) + } + bitmap.recycle() + + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file + ) + results.add(RasterPage(i, uri, file)) + } + onProgress?.invoke(i + 1, total) + } + } + } + return results + } + + /** Persist all rendered pages to the shared Pictures collection via MediaStore. */ + fun exportToGallery(context: Context, pages: List, format: ImageFormat, albumName: String = "ClearPDF"): Int { + var saved = 0 + val resolver = context.contentResolver + pages.forEach { page -> + val values = android.content.ContentValues().apply { + put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, page.file.name) + put(android.provider.MediaStore.Images.Media.MIME_TYPE, format.mime) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + put( + android.provider.MediaStore.Images.Media.RELATIVE_PATH, + "${android.os.Environment.DIRECTORY_PICTURES}/$albumName" + ) + } + } + val collection = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + android.provider.MediaStore.Images.Media.getContentUri(android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val item = resolver.insert(collection, values) ?: return@forEach + runCatching { + resolver.openOutputStream(item)?.use { out -> + page.file.inputStream().use { it.copyTo(out) } + } + saved++ + } + } + return saved + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/text/PdfTextService.kt b/pdf-core/src/main/java/com/kyant/pdfcore/text/PdfTextService.kt new file mode 100644 index 0000000..e51763e --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/text/PdfTextService.kt @@ -0,0 +1,199 @@ +package com.kyant.pdfcore.text + +import android.content.Context +import android.net.Uri +import com.kyant.pdfcore.internal.PdfBox +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.text.PDFTextStripper +import com.tom_roush.pdfbox.text.TextPosition +import kotlin.math.abs +import kotlin.math.min + +data class PdfTextBlock( + val id: String, + val text: String, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + // Per-text-character normalized x bounds, parallel to [text]. Enable word-precise + // highlighting of a matched substring instead of the whole line. + val charLefts: FloatArray = FloatArray(0), + val charRights: FloatArray = FloatArray(0) +) + +/** A search hit as a normalized rect around the exact matched word(s), not the whole line. */ +data class PdfSearchMatch( + val pageIndex: Int, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float +) + +interface PdfTextService { + fun extractPage(context: Context, uri: Uri, pageIndex: Int): List + fun searchAll(context: Context, uri: Uri, query: String, pageCount: Int): List +} + +class PdfTextServiceImpl : PdfTextService { + + override fun extractPage(context: Context, uri: Uri, pageIndex: Int): List { + PdfBox.ensureInitialized(context) + return runCatching { + context.contentResolver.openInputStream(uri)?.use { stream -> + PDDocument.load(stream).use { doc -> + if (pageIndex !in 0 until doc.numberOfPages) return@runCatching emptyList() + extractPageBlocks(doc, pageIndex) + } + } ?: emptyList() + }.getOrElse { emptyList() } + } + + override fun searchAll( + context: Context, + uri: Uri, + query: String, + pageCount: Int + ): List { + if (query.isBlank()) return emptyList() + PdfBox.ensureInitialized(context) + val lower = query.trim().lowercase() + return runCatching { + context.contentResolver.openInputStream(uri)?.use { stream -> + PDDocument.load(stream).use { doc -> + buildList { + for (pageIdx in 0 until min(doc.numberOfPages, pageCount)) { + extractPageBlocks(doc, pageIdx).forEach { block -> + addAll(block.matchRects(lower, pageIdx)) + } + } + } + } + } ?: emptyList() + }.getOrElse { emptyList() } + } + + private fun extractPageBlocks(doc: PDDocument, pageIndex: Int): List { + val page = doc.getPage(pageIndex) + val rotation = page.rotation + val box = page.cropBox ?: page.mediaBox ?: return emptyList() + val pageW: Float + val pageH: Float + if (rotation == 90 || rotation == 270) { + pageW = box.height + pageH = box.width + } else { + pageW = box.width + pageH = box.height + } + if (pageW <= 0f || pageH <= 0f) return emptyList() + + val stripper = PositionCapturingStripper().apply { + startPage = pageIndex + 1 + endPage = pageIndex + 1 + } + runCatching { stripper.getText(doc) } + + return groupPositionsIntoLines(stripper.positions, pageW, pageH, pageIndex) + } + + private fun groupPositionsIntoLines( + positions: List, + pageWidth: Float, + pageHeight: Float, + pageIndex: Int + ): List { + if (positions.isEmpty()) return emptyList() + + val avgH = positions.map { it.height }.average().toFloat().coerceAtLeast(2f) + val lineGap = avgH * 0.6f + + // Group by similar Y (baseline) into lines + val sorted = positions.sortedBy { it.y } + val lines = mutableListOf>() + for (pos in sorted) { + val last = lines.lastOrNull() + if (last == null || abs(pos.y - last.first().y) > lineGap) { + lines.add(mutableListOf(pos)) + } else { + last.add(pos) + } + } + + return lines.mapIndexedNotNull { lineIdx, linePositions -> + val byX = linePositions.sortedBy { it.x } + // Build the line text AND per-character x bounds together so indices stay aligned. + val sb = StringBuilder() + val cl = ArrayList() + val cr = ArrayList() + var lastRight = -Float.MAX_VALUE + byX.forEach { tp -> + val u = tp.unicode ?: "" + if (u.isEmpty()) return@forEach + val gap = tp.x - lastRight + if (lastRight > -Float.MAX_VALUE && gap > tp.width * 0.4f) { + sb.append(' '); cl.add(lastRight); cr.add(tp.x) + } + val x0 = tp.x; val x1 = tp.x + tp.width; val n = u.length + for (i in u.indices) { + sb.append(u[i]) + cl.add(x0 + (x1 - x0) * i / n) + cr.add(x0 + (x1 - x0) * (i + 1) / n) + } + lastRight = x1 + } + val raw = sb.toString() + val startI = raw.indexOfFirst { !it.isWhitespace() } + val endI = raw.indexOfLast { !it.isWhitespace() } + if (startI < 0) return@mapIndexedNotNull null + val text = raw.substring(startI, endI + 1) + val charLefts = FloatArray(endI - startI + 1) { (cl[startI + it] / pageWidth).coerceIn(0f, 1f) } + val charRights = FloatArray(endI - startI + 1) { (cr[startI + it] / pageWidth).coerceIn(0f, 1f) } + + val minX = byX.minOf { it.x } + val maxX = byX.maxOf { it.x + it.width } + val minY = byX.minOf { it.y - it.height }.coerceAtLeast(0f) + val maxY = byX.maxOf { it.y } + + PdfTextBlock( + id = "$pageIndex-$lineIdx", + text = text, + left = (minX / pageWidth).coerceIn(0f, 1f), + top = (minY / pageHeight).coerceIn(0f, 1f), + right = (maxX / pageWidth).coerceIn(0f, 1f), + bottom = (maxY / pageHeight).coerceIn(0f, 1f), + charLefts = charLefts, + charRights = charRights + ) + } + } +} + +/** All occurrences of [lower] (already lower-cased) in this line, as tight word rects. */ +fun PdfTextBlock.matchRects(lower: String, pageIndex: Int): List { + if (lower.isEmpty() || charLefts.isEmpty()) return emptyList() + val bt = text.lowercase() + val out = ArrayList() + var from = 0 + while (true) { + val idx = bt.indexOf(lower, from) + if (idx < 0) break + val endC = idx + lower.length - 1 + if (idx < charLefts.size && endC < charRights.size) { + out.add(PdfSearchMatch(pageIndex, charLefts[idx], top, charRights[endC], bottom)) + } else { + out.add(PdfSearchMatch(pageIndex, left, top, right, bottom)) + } + from = idx + lower.length + } + return out +} + +private class PositionCapturingStripper : PDFTextStripper() { + val positions = mutableListOf() + + override fun processTextPosition(text: TextPosition) { + if (!text.unicode.isNullOrBlank()) positions.add(text) + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/viewer/PdfViewer.kt b/pdf-core/src/main/java/com/kyant/pdfcore/viewer/PdfViewer.kt index bc78b62..25cf588 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/viewer/PdfViewer.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/viewer/PdfViewer.kt @@ -81,7 +81,14 @@ class PdfViewerImpl : PdfViewer { val renderer = renderers[document.uri] ?: return null if (pageIndex < 0 || pageIndex >= renderer.pageCount) return null - val page = renderer.openPage(pageIndex) + // `openPage` is a native call that can fail on a page some producers (e.g. a PdfBox- + // decrypted copy) emit in a form pdfium dislikes; treat any failure as "no bitmap". + val page = try { + renderer.openPage(pageIndex) + } catch (t: Throwable) { + return@withLock null + } + var bitmap: Bitmap? = null try { val rotation = ((rotationDegrees % 360) + 360) % 360 val swap = rotation == 90 || rotation == 270 @@ -95,7 +102,7 @@ class PdfViewerImpl : PdfViewer { val targetW = width.coerceAtLeast(1) val targetH = (targetW * (dispH / dispW)).toInt().coerceAtLeast(1) - val bitmap = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888) + bitmap = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888) bitmap.eraseColor(android.graphics.Color.WHITE) // Map the source page into the (possibly rotated) target bitmap. @@ -115,7 +122,12 @@ class PdfViewerImpl : PdfViewer { page.render(bitmap, null, matrix, android.graphics.pdf.PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) bitmap - } catch (e: Exception) { + } catch (t: Throwable) { + // Catch Throwable, not just Exception: a large page can throw OutOfMemoryError from + // Bitmap.createBitmap, which is an Error β€” the old `catch (Exception)` let it escape and + // crash the app mid-scroll (worst on big / decrypted PDFs). Free any partial bitmap so a + // failed render doesn't itself leak the memory that caused the failure. + bitmap?.recycle() null } finally { runCatching { page.close() } diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/watermark/PdfWatermarker.kt b/pdf-core/src/main/java/com/kyant/pdfcore/watermark/PdfWatermarker.kt new file mode 100644 index 0000000..d3203a0 --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/watermark/PdfWatermarker.kt @@ -0,0 +1,150 @@ +package com.kyant.pdfcore.watermark + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.net.Uri +import com.kyant.pdfcore.internal.PdfBox +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.pdmodel.PDPageContentStream +import com.tom_roush.pdfbox.pdmodel.font.PDType1Font +import com.tom_roush.pdfbox.pdmodel.graphics.image.LosslessFactory +import com.tom_roush.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState +import com.tom_roush.pdfbox.util.Matrix +import kotlin.math.min + +/** + * Stamps a repeated text watermark onto every page of a PDF via PDFBox. The source URI is + * never modified; the result is written to [destinationUri]. All work runs on the caller's + * worker thread (same contract as the other pdf-core services). + * + * Original implementation for ClearPDF β€” modeled on the app's existing text-stamp export path + * (PDExtendedGraphicsState alpha + PDType1Font.showText), adapted to a page-centered watermark. + */ +object PdfWatermarker { + + /** + * @param text the watermark string (blank is a no-op that still copies the file). + * @param opacity 0..1 fill alpha for the text. + * @param diagonal true = 45Β° watermark centered on the page; false = horizontal centered. + * @param colorArgb watermark colour (default a neutral grey). + */ + fun apply( + context: Context, + sourceUri: Uri, + destinationUri: Uri, + text: String, + opacity: Float, + diagonal: Boolean, + colorArgb: Int = 0xFF8A8A8A.toInt() + ) { + PdfBox.ensureInitialized(context) + val sanitized = sanitize(text) + val c = Color.valueOf(colorArgb) + val alpha = opacity.coerceIn(0.05f, 1f) + + context.contentResolver.openInputStream(sourceUri)?.use { input -> + PDDocument.load(input).use { doc -> + if (sanitized.isNotBlank()) { + val font = PDType1Font.HELVETICA_BOLD + for (i in 0 until doc.numberOfPages) { + val page = doc.getPage(i) + val boxRect = page.cropBox ?: page.mediaBox ?: continue + val w = boxRect.width + val h = boxRect.height + val originX = boxRect.lowerLeftX + val originY = boxRect.lowerLeftY + // Scale font to the page's short edge so the watermark reads on any size. + val fontSize = (min(w, h) * 0.11f).coerceIn(18f, 120f) + val textWidth = font.getStringWidth(sanitized) / 1000f * fontSize + val cx = originX + w / 2f + val cy = originY + h / 2f + + PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> + cs.saveGraphicsState() + val gs = PDExtendedGraphicsState().apply { nonStrokingAlphaConstant = alpha } + cs.setGraphicsStateParameters(gs) + cs.setNonStrokingColor(c.red(), c.green(), c.blue()) + cs.beginText() + cs.setFont(font, fontSize) + if (diagonal) { + // Rotate the text frame 45Β° about the page centre, then shift so the + // string's midpoint sits on the centre. + cs.setTextMatrix(Matrix.getRotateInstance(Math.toRadians(45.0), cx, cy)) + cs.newLineAtOffset(-textWidth / 2f, -fontSize / 2.6f) + } else { + cs.newLineAtOffset(cx - textWidth / 2f, cy - fontSize / 2.6f) + } + runCatching { cs.showText(sanitized) } + cs.endText() + cs.restoreGraphicsState() + } + } + } + context.contentResolver.openOutputStream(destinationUri)?.use { output -> + doc.save(output) + } ?: throw IllegalStateException("Unable to write PDF") + } + } ?: throw IllegalStateException("Unable to read PDF") + } + + /** + * Stamp an [bitmap] image watermark (e.g. a logo) centered on every page. + * + * @param opacity 0..1 image alpha. + * @param diagonal true = rotate the image 45Β° about the page centre. + * @param widthFraction the watermark width as a fraction of the page width. + */ + fun applyImage( + context: Context, + sourceUri: Uri, + destinationUri: Uri, + bitmap: Bitmap, + opacity: Float, + diagonal: Boolean, + widthFraction: Float = 0.45f + ) { + PdfBox.ensureInitialized(context) + val alpha = opacity.coerceIn(0.05f, 1f) + val ratio = bitmap.height.toFloat() / bitmap.width.toFloat().coerceAtLeast(1f) + + context.contentResolver.openInputStream(sourceUri)?.use { input -> + PDDocument.load(input).use { doc -> + val image = LosslessFactory.createFromImage(doc, bitmap) + for (i in 0 until doc.numberOfPages) { + val page = doc.getPage(i) + val box = page.cropBox ?: page.mediaBox ?: continue + val w = box.width + val h = box.height + val cx = box.lowerLeftX + w / 2f + val cy = box.lowerLeftY + h / 2f + val drawW = w * widthFraction.coerceIn(0.1f, 1f) + val drawH = drawW * ratio + + PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> + cs.saveGraphicsState() + val gs = PDExtendedGraphicsState().apply { + nonStrokingAlphaConstant = alpha + strokingAlphaConstant = alpha + } + cs.setGraphicsStateParameters(gs) + if (diagonal) { + cs.transform(Matrix.getRotateInstance(Math.toRadians(45.0), cx, cy)) + cs.drawImage(image, -drawW / 2f, -drawH / 2f, drawW, drawH) + } else { + cs.drawImage(image, cx - drawW / 2f, cy - drawH / 2f, drawW, drawH) + } + cs.restoreGraphicsState() + } + } + context.contentResolver.openOutputStream(destinationUri)?.use { output -> + doc.save(output) + } ?: throw IllegalStateException("Unable to write PDF") + } + } ?: throw IllegalStateException("Unable to read PDF") + } + + /** PDFBox's WinAnsi encoding rejects unsupported glyphs; keep to Latin-1. */ + private fun sanitize(text: String): String = + buildString { text.forEach { ch -> append(if (ch.code in 32..255) ch else '?') } } +} diff --git a/plan.txt b/plan.txt new file mode 100644 index 0000000..165d23a --- /dev/null +++ b/plan.txt @@ -0,0 +1,197 @@ +# ClearPDF β€” UI/UX + Feature Completion Plan (temporary) +# Work through these ONE BY ONE, patiently, verifying each compiles + doesn't break existing flows. +# Mark [x] when done. Keep the PDF/document content readable and dominant. Liquid-glass ONLY for floating UI. + +===================================================================== +ITEM 1 β€” SAVE DOCUMENT β†’ REAL IN-WINDOW GLASS [x] DONE (viewer). Tool screens keep solid Dialog. +--------------------------------------------------------------------- +- LiquidSaveDialog is currently a SOLID themed card in a Dialog window (can't sample the page). +- Convert to an IN-WINDOW overlay (like LiquidPageJumpPopup) rendered inside the viewer's + BoxWithConstraints so liquidGlassPanel samples the real content backdrop (contentBackdrop). +- Real glass: translucent + blur + subtle border/highlight/shadow. NO png, NO solid slab. +- Keep: filename field, folder picker launcher, save location display. imePadding for keyboard. +- Redesign the save-location section: current location obvious, compact rows, rounded cards, + M3 rounded icons (Downloads / Documents / custom folder / create-new-folder). +- Also used by the tool screens (Merge/Split/etc.) β†’ those stay as-is (over wallpaper) OR + give a solid card fallback when no contentBackdrop. Decide cleanly, don't break them. + +===================================================================== +ITEM 2 β€” TEXT SELECTION + FIND OVERHAUL [x] DONE + [x] FIND now highlights the exact matched WORD (not the whole sentence/line). Studied Pdf_Tools + (PDFTextStripper per-char TextPosition). We already captured char TextPositions in + PdfTextService but collapsed to line blocks; now carry per-char normalized x-bounds + (charLefts/charRights) up: PdfTextBlock + OcrTextBlock, and FindMatch is now a normalized + WORD rect (was blockId). Search (searchText + searchTextInDocument + PdfTextService.searchAll) + computes tight per-occurrence rects; PdfContinuousPage/PdfPageCanvas draw the word rect + (rounded, padded). Multiple matches per line supported. + [x] Contextual selection bubble added in PdfContinuousPage: when text is selected (SelectText), + a compact glass bubble (Copy | Highlight) appears just above the selection's bounding rect, + flipping BELOW when near the page top and clamped to page bounds. Wired onCopySelection / + onHighlightSelection callbacks from PdfViewerScreen (copy selected OCR text to clipboard; + add TextBlockHighlightMarkup for selected ids). NOTE: bubble lives inside the page's zoom + layer so it scales with zoom β€” fine for text-selection (done at ~1x); could counter-scale later. + (Kept Search/Share/Annotate out for now to stay compact; can add if wanted.) +--------------------------------------------------------------------- +- Study Pdf_Tools text selection/search approach (char-position based selection, handles). +- Selection: clear handles, subtle highlight, contextual action bubble near selection + (Copy / Select All / Search / Share / Highlight / Annotate), smart-positioned so it never + goes off-screen (flip above/below near edges). +- Find: compact floating search toolbar, "n / total", prev/next, scroll-to-match, smooth + transitions between matches, clean close, never obscures a large part of the doc. +- Feature-rich + quality-rich. Keep our OCR-block model; adapt Pdf_Tools ideas, don't copy code. + +===================================================================== +ITEM 3 β€” SETTINGS FULL REDESIGN [x] DONE + β†’ SettingsScreen.kt rewritten around 3 reusable primitives + centralized tokens: + - SettingsSection(title, icon, iconTint, titleColor, contentSpacing, trailing, content): + glass card + M3 rounded-icon TILE header (was a bare icon) + optional trailing slot + (used by the quality % badge). Replaces 7 hand-built headers. + - SegmentedSelector(options, selectedId, onSelect): one equal-weight glass segmented + control shared by Theme / Language / Save-location β€” replaces 3 copy-pasted blocks, + guarantees consistent overflow (maxLines=1 + ellipsis). SegOption carries optional icon. + - reveal(index, visible, density): one staggered fade+rise entrance modifier, replaces + 12 hand-written animateFloatAsState + graphicsLayer blocks AND fixes a real bug where + Quality + Language both reused panel4's animation. SettingsDivider extracted too. + - Tokens: SectionRadius/InnerRadius/SectionGap/SectionPadding/TitleSize/AccentBlue/Green. + - Overflow: section titles + save path now maxLines+ellipsis; dark-mode palette preserved. + β†’ Preserved EVERY backing call (AppSettingsManager auto-compress/keep-original/notifications/ + quality, SaveLocationManager + folder picker, onThemeModeChanged, onLocaleChanged, star CTA, + licenses). Function signature unchanged. No fake settings added (no privacy link target). BUILD OK. + +ITEM 3 β€” (original spec below) [x] +--------------------------------------------------------------------- +- Rebuild hierarchy (only real settings, no fake ones): + Appearance: Theme, (Accent/Dynamic if they exist), + Files: Default save location, File naming (if exists), Recent files (if exists) + Editor: selection/drawing/highlight defaults (only if they exist) + About: Version, Licenses (AndroidLiquidGlass + Pdf_Tools credit), Open source, Privacy +- Preserve ALL existing functionality/toggles/pickers. +- Fix every typography/overflow/dark-mode issue (audit each row: long text, pt, small screens, + font scaling, multiline, icon collisions). Use constraints, not hardcoded widths. +- M3 rounded icons, consistent spacing/corner-radii/typography tokens. + +===================================================================== +ITEM 4 β€” LIQUID GLASS DESIGN SYSTEM EXTRACTION [x] DONE (core + 2 adopters) + β†’ New components/GlassPrimitives.kt centralizes the design system: + - GlassDimens tokens (ScreenPadding/SectionGap/SectionRadius/InnerRadius/ + SectionPadding/TitleSize) β€” single source of truth for spacing/radii/type. + - Modifier.glassSection(isLight, radius): the solid section card (was a private + copy in SettingsScreen). + - GlassSectionHeader(title, icon, iconTint, titleColor, trailing): M3 rounded + icon-tile header, reusable. + - GlassChip(text, color): the tinted value pill (e.g. quality %). + - ToolScaffold(title, backdrop, onBack, headerTrailing, content): the shared + animated back-header + staggered reveal + scroll body that every tool screen + hand-rolled (~45 lines each). + β†’ Adopted in SettingsScreen (removed its private liquidGlassSection + inlined header + + badge β†’ shared primitives) and CompressPdfScreen (now ToolScaffold + GlassChip). + Both BUILD OK. + β†’ NOT force-migrated: PageOrganizer (reorderable list), ImagesToPdf (grid), + Encrypt/Decrypt (no reveal), Scan (no back) β€” their body structure differs from the + simple verticalScroll ToolScaffold assumes; migrating blind (no device eyes) risks + layout regressions for zero user-visible gain. They can adopt ToolScaffold incrementally + during a device-verified pass. Primitives are ready for them. + +ITEM 4 β€” (original spec below) [x] +--------------------------------------------------------------------- +- One consistent set of reusable primitives (reuse existing LiquidButton/LiquidIconButton/ + liquidGlassPanel; add GlassCard/GlassSection/GlassSheet/GlassChip as thin wrappers). +- Centralize spacing / corner radii / typography / glass tint tokens. +- Do NOT make everything glass. Document/page stays solid + readable. +- De-duplicate copy-pasted glass styling across screens. + +===================================================================== +ITEM 5 β€” PDF_TOOLS FEATURE-GAP PORTING [~] WATERMARK DONE (first gap closed this pass) + β†’ Gap audit (from memory): MISSING = watermark, image tools, extract-pages-to-new-PDF, + HTML->PDF, form fill/flatten, OCR. Watermark chosen first: highest value, most + self-contained, maps directly onto the existing PDFBox text-stamp export path. + β†’ Implemented END-TO-END (original code, no third-party paste): + - pdf-core/watermark/PdfWatermarker.apply(ctx, src, dest, text, opacity, diagonal, color): + stamps a page-centered text watermark on every page via PDDocument + PDPageContentStream + (APPEND) + PDExtendedGraphicsState.nonStrokingAlphaConstant for opacity + + PDType1Font.HELVETICA_BOLD; diagonal = 45Β° via Matrix.getRotateInstance about page centre; + font scales to the page short edge; Latin-1 glyph sanitize; source URI never modified. + - WatermarkPdfViewModel (self-contained, viewModel(), no usecase): text/opacity/diagonal + state + apply() writes via SaveLocationManager createOutputUri (custom folder or Downloads) + + RecentFilesManager, same as Compress/PdfToImages. + - WatermarkPdfScreen: built on the NEW ToolScaffold primitive (Item 4) β€” pick card, text + field, opacity slider + GlassChip %, diagonal LiquidToggle, Apply, open-output. + - Wired: DocsNavGraph ROUTE_WATERMARK + composable; ToolsScreen param + ToolSpec + (BrandingWatermark icon, #AD1457); strings tool_watermark(_sub)/watermark_* in en+pt+pt-rBR. + BUILD SUCCESSFUL. Not device-verified. + β†’ EXTRACT PAGES DONE (2nd gap): `ExtractPagesViewModel` (self-contained) reuses + `PdfSplitterImpl.extractPages` with a 1-based range parser ("1-3, 5, 8-10" β†’ 0-based + indices, clamped/deduped/sorted); saves via SaveLocationManager + RecentFilesManager. + `ExtractPagesScreen` on ToolScaffold (numeric range field, page-count trailing, apply, + open-output). Wired: DocsNavGraph ROUTE_EXTRACT_PAGES, ToolsScreen ToolSpec + (ContentCut icon, #00897B), strings en+pt+pt-rBR. assembleDebug OK. + β†’ PAGE NUMBERS DONE (3rd tool this pass): `pdf-core/pagenumber/PdfPageNumberer.apply(ctx, + src, dest, position CENTER/RIGHT, includeTotal, startAt)` stamps a page number in the + bottom margin of every page (same PDFBox HELVETICA text-stamp path as watermark). + `PageNumbersViewModel` + `PageNumbersScreen` (ToolScaffold, position selector, show-total + toggle) + nav ROUTE_PAGE_NUMBERS + ToolsScreen ToolSpec (Icons.Rounded.Numbers, #3949AB) + + strings en+pt+pt-rBR. assembleDebug OK. + β†’ FLATTEN DONE: pdf-core/flatten/PdfFlattener (PDAcroForm.flatten(), returns field count) + + FlattenPdfViewModel + FlattenPdfScreen (ToolScaffold) + nav ROUTE_FLATTEN + ToolSpec + (Layers #6D4C41) + strings. + β†’ IMAGE TOOLS DONE: pdf-core/image/ImageProcessor (inspect/process/saveToGallery) β€” decode + (inSampleSize downscale) β†’ scale by % β†’ re-encode JPG/PNG/WebP at quality; re-encode strips + EXIF/GPS by construction; JPEG flattens alpha onto white. Reuses PdfRasterizer.ImageFormat. + ImageToolsViewModel + ImageToolsScreen (ToolScaffold: format selector, quality slider [lossy + only], resize slider, before/after size, Save-to-gallery + Share) + nav ROUTE_IMAGE_TOOLS + + ToolSpec (PhotoSizeSelectLarge #F4511E) + strings en+pt+pt-rBR. assembleDebug OK. + β†’ HTML->PDF DONE: util/HtmlToPdfConverter (offscreen WebView @A4 β†’ PdfDocument page slices, + main-thread async; JS off + null base = offline-only local HTML) + HtmlToPdfViewModel + (suspendCancellableCoroutine on Dispatchers.Main) + HtmlToPdfScreen + nav + ToolSpec (Code #E65100). + β†’ FILL FORMS DONE: pdf-core/form/PdfFormService (readFields β†’ text/checkbox/choice terminal + fields; fill(values, flatten) via PDField.setValue/check/unCheck) + FillFormViewModel (editable + field list) + FillFormScreen (dynamic typed inputs + flatten toggle) + nav + ToolSpec (EditNote #00695C). + β†’ ALL Pdf_Tools feature-list gaps now CLOSED (Flatten, Image Tools, HTMLβ†’PDF, Fill Forms this pass; + Watermark/Extract-Pages/Page-Numbers earlier). Tools grid = 17 cards. assembleDebug OK. + NONE device-verified β€” WebView HTMLβ†’PDF timing + form appearance generation especially want it. + +ITEM 5 β€” (original spec below) [~] +--------------------------------------------------------------------- +- Feature-gap analysis first: what exists / missing / portable / conflicts-with-architecture. +- Implement highest-value MISSING features (candidates: form fill/flatten, watermark, + page extract-to-new-pdf, image tools, HTML->PDF, better OCR) β€” adapt to our PDFBox/PdfRenderer + + PdfMarkup/ExportOverlay model. No unnecessary deps. No blind code copy. Credit already given. + +===================================================================== +BUGS TO FIX (from user) β€” do alongside the items above [ ] +--------------------------------------------------------------------- +BUG A β€” UNDO first-attempt failure [x] DONE: + "sometimes on first attempt the undo doesnt work at all for anything any modification unless i + come back to homescreen and reopen the pdf." + β†’ ROOT CAUSE: the bottom toolbar's undo + all page-scoped OCR/highlight actions targeted + getPageMarks(state.currentPage), where state.currentPage is an ASYNC mirror updated via + snapshotFlow{ firstVisibleItemIndex }.collect{ onPageChanged }. Drawing/placement inside the + LazyColumn item targets getPageMarks(page) with the SYNCHRONOUS item index. Right after + open/scroll (before the flow settles) the two diverge, so undo removed from the wrong (empty) + page list = no-op; reopening resynced them. + β†’ FIX: switched currentPageMarks + selectedTextCount/currentSelectedIds + onSelectAllText/ + onCopyText/onHighlight/onUnderline/onStrike/onClearTextSelection + onSetActiveTool's + clearOcrSelection + the "Page X / Y" pill from state.currentPage to the synchronous + currentPageIndex (= listState.firstVisibleItemIndex, same source the drawing uses). + state.currentPage is kept only for the async OCR-extraction trigger. BUILD SUCCESSFUL. + +BUG B β€” Signature resize [x] DONE: PdfContinuousPage image selection redesigned β€” rounded + accent frame, 3 passive corner dots, and a large (22px) bottom-right resize handle with a + diagonal glyph; grab radius 40fβ†’64f. (Apple-style: touch target >> visual handle.) + +BUG B (original text) β€” Signature resize is fiddly: + "difficult to resize the signature by dragging that small thing/line β€” improve it, follow Apple HIG, + unique sweet UI, best approach." + β†’ The image/signature resize handle is a tiny corner dot with a 36-40px hit slop. Redesign: + larger, clearer Apple-style corner handles (visible rounded handles at corners), bigger touch + target (44dp+), maybe a bounding frame with grab handles, smooth. Keep aspect for signatures. + Consider a dedicated resize affordance + move handle. Touch target >= visual size. + +===================================================================== +GLOBAL RULES +--------------------------------------------------------------------- +- Compile (:app:compileDebugKotlin) after each item; don't break opening/render/nav/zoom/scroll/ + select/search/annotate/draw/highlight/sign/saved-sigs/save/export/file-picker/settings/darkmode. +- Apple-quality: hierarchy, spacing, typography, restraint, depth, contextual controls, subtle motion. +- pt labels ~1.3x longer than EN β†’ width-constrained buttons need maxLines=1. +- Keep memory (session-work-status.md) updated per item. diff --git a/readme.md b/readme.md index 30bf4d7..d42ead7 100644 --- a/readme.md +++ b/readme.md @@ -54,6 +54,21 @@ --- +# 🎬 Demo + +Explore the included ClearPDF walkthroughs and editing-tool previews: + +- [▢️ Onboarding walkthrough](demo/1.mp4) +- [▢️ PDF viewer walkthrough](demo/2.mp4) + +

+ ClearPDF editing tools preview 1 + ClearPDF editing tools preview 2 + ClearPDF editing tools preview 3 +

+ +--- + # 🧊 Liquid Glass UI ClearPDF uses a custom Android liquid glass inspired design system with: diff --git a/scratch/AndroidLiquidGlass b/scratch/AndroidLiquidGlass deleted file mode 160000 index b18eb0f..0000000 --- a/scratch/AndroidLiquidGlass +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b18eb0ff12c616546a68c72e7d0097f1ab286c87