diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..00212d8 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,25 @@ +# Third-party notices + +ClearPDF uses open-source components and keeps their notices with the project. Release packaging must preserve the applicable copyright and license text. + +## Apache PDFBox Android + +- Artifact: `com.tom-roush:pdfbox-android:2.0.27.0` +- License: Apache License 2.0 +- Use: PDF parsing, editing, merging, splitting, and password removal. +- Notice practice: keep the Apache 2.0 license and upstream attribution available with redistributed builds. Do not imply that the Apache Software Foundation endorses ClearPDF. + +## Apache POI + +- Artifacts: `org.apache.poi:poi:3.17`, `org.apache.poi:poi-scratchpad:3.17` +- License: Apache License 2.0 +- Use: text extraction from legacy `.doc`, `.xls`, and `.ppt` files. Modern Office Open XML files use the platform ZIP/XML parser for a smaller footprint. +- Notice practice: retain the Apache 2.0 license and any bundled dependency notices when distributing an APK or source package. + +## AndroidLiquidGlass / Backdrop + +- Component: `AndroidLiquidGlass` / the local `backdrop` module +- License: Apache License 2.0 (as attributed in the Settings screen) +- Use: the app's translucent glass surfaces, backdrop effects, and shared UI components. + +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 1dec36b..e57b729 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,7 +40,9 @@ android { targetSdk = 36 versionCode = 2 versionName = "1.1.0" - androidResources.localeFilters += arrayOf("en") + // 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. } signingConfigs { @@ -126,6 +128,10 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(project(":backdrop")) implementation(project(":pdf-core")) + // Apache POI provides legacy .doc/.xls/.ppt text extraction. It is Apache-2.0 + // licensed; see THIRD_PARTY_NOTICES.md for redistribution requirements. + implementation("org.apache.poi:poi:3.17") + implementation("org.apache.poi:poi-scratchpad:3.17") // ML Kit Document Scanner & Camera implementation(libs.play.services.mlkit.scanner) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 856506c..47b7fe2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -100,6 +100,13 @@ + + + + + + + diff --git a/app/src/main/java/com/chethan616/clearpdf/MainActivity.kt b/app/src/main/java/com/chethan616/clearpdf/MainActivity.kt index b89ab46..207fa40 100644 --- a/app/src/main/java/com/chethan616/clearpdf/MainActivity.kt +++ b/app/src/main/java/com/chethan616/clearpdf/MainActivity.kt @@ -45,7 +45,8 @@ class MainActivity : ComponentActivity() { } } - // Handle PDF file opened from other apps (VIEW/SEND) + // Handle supported documents opened from other apps (VIEW/SEND). + // The viewer converts office/text/image sources into a local PDF preview. val incomingPdfUri: Uri? = when (intent?.action) { Intent.ACTION_VIEW -> intent.data Intent.ACTION_SEND -> { @@ -57,7 +58,7 @@ class MainActivity : ComponentActivity() { } } else -> null - }?.takeIf { intent?.type == "application/pdf" || intent?.data?.toString()?.endsWith(".pdf") == true } + }?.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 @@ -67,6 +68,23 @@ class MainActivity : ComponentActivity() { } } + private fun isSupportedDocumentIntent(mimeType: String?, uriString: String): Boolean { + val lower = uriString.lowercase() + val supportedExtension = listOf( + ".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".csv", ".txt", ".rtf", + ".odt", ".ods", ".odp", ".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".heic" + ).any(lower::contains) + return mimeType == null || + mimeType == "application/pdf" || + mimeType?.startsWith("image/") == true || + mimeType?.startsWith("text/") == true || + mimeType?.contains("word", ignoreCase = true) == true || + mimeType?.contains("excel", ignoreCase = true) == true || + mimeType?.contains("spreadsheet", ignoreCase = true) == true || + mimeType?.contains("presentation", ignoreCase = true) == true || + supportedExtension + } + private fun requestHighRefreshRate() { // Prefer the display mode with the highest refresh rate if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/SignatureManager.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/SignatureManager.kt index 66abb75..66c07f3 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/SignatureManager.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/SignatureManager.kt @@ -25,9 +25,17 @@ object SignatureManager { ?.sortedByDescending { it.lastModified() } ?: emptyList() - /** Saves a transparent-background signature bitmap; returns the saved file. */ - fun saveSignature(context: Context, bitmap: Bitmap): File { - val file = File(dir(context), "sig_${System.currentTimeMillis()}.png") + /** Saves a transparent-background signature bitmap using a human-readable name. */ + fun saveSignature(context: Context, bitmap: Bitmap, name: String = "Signature"): File { + val safeName = name + .trim() + .ifBlank { "Signature" } + .replace(Regex("[^\\p{L}\\p{N}\\-_ ]"), "") + .trim() + .replace(Regex("\\s+"), "_") + .take(48) + .ifBlank { "Signature" } + val file = File(dir(context), "${safeName}_${System.currentTimeMillis()}.png") FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) out.flush() @@ -35,6 +43,19 @@ object SignatureManager { return file } + /** Returns the display name stored in a signature filename. */ + fun displayName(file: File): String { + val base = file.nameWithoutExtension + val withoutTimestamp = base.substringBeforeLast('_').takeIf { + base.substringAfterLast('_').toLongOrNull() != null + } ?: base + return withoutTimestamp + .removePrefix("sig") + .replace('_', ' ') + .trim() + .ifBlank { "Signature" } + } + /** Loads a signature from file. Returns null if the file is missing or corrupt. */ fun loadSignature(file: File): Bitmap? = try { val options = BitmapFactory.Options().apply { 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 9f76ea5..4164919 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt @@ -34,7 +34,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource +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 @@ -67,6 +69,9 @@ import kotlinx.coroutines.flow.collectLatest fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = null) { val context = LocalContext.current var selectedLocale by rememberSaveable { mutableStateOf(OnboardingManager.getSelectedLocale(context)) } + val localizedContext = remember(context, selectedLocale) { + com.chethan616.clearpdf.ui.utils.LocaleHelper.getLocalizedContext(context, selectedLocale) + } var themeMode by rememberSaveable { mutableIntStateOf(AppSettingsManager.getThemeMode(context)) } var showStarPrompt by rememberSaveable { mutableStateOf(false) } @@ -154,13 +159,8 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu contentScale = ContentScale.Crop ) - LaunchedEffect(selectedLocale) { - if (selectedLocale.isNotBlank()) { - com.chethan616.clearpdf.ui.utils.LocaleHelper.applyLocale(context, selectedLocale, recreate = false) - } - } - CompositionLocalProvider( + LocalResources provides localizedContext.resources, LocalIsDarkMode provides isDarkMode ) { Box(Modifier.fillMaxSize()) { @@ -176,6 +176,19 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu themeMode = it AppSettingsManager.setThemeMode(context, it) }, + selectedLocale = selectedLocale, + onLocaleChanged = { code -> + val normalized = com.chethan616.clearpdf.ui.utils.LocaleHelper.normalizeForUi(code) + if (normalized != selectedLocale) { + selectedLocale = normalized + com.chethan616.clearpdf.ui.utils.LocaleHelper.applyLocale( + context = context, + languageTag = normalized, + recreate = false, + updateAppCompat = false + ) + } + }, incomingPdfUri = incomingPdfUri ) @@ -247,11 +260,11 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu Modifier.size(52.dp), starAccent ) BasicText( - "Support ClearPDF", + stringResource(R.string.star_prompt_title), style = TextStyle(starText, 20.sp, FontWeight.Bold, textAlign = TextAlign.Center) ) BasicText( - "ClearPDF is open source on GitHub.\nWould you like to star the project?", + stringResource(R.string.star_prompt_message), style = TextStyle(starSub, 14.sp, textAlign = TextAlign.Center) ) Spacer(Modifier.height(4.dp)) @@ -270,7 +283,7 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Rounded.Star, null, Modifier.size(18.dp), Color.White) - BasicText("Yes, Star It", style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.star_prompt_accept), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold)) } } Row( @@ -279,7 +292,7 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu verticalAlignment = Alignment.CenterVertically ) { BasicText( - "Not Now", + stringResource(R.string.star_prompt_later), style = TextStyle(starSub, 13.sp, FontWeight.Medium, textAlign = TextAlign.Center), modifier = Modifier .clickable { @@ -293,7 +306,7 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu style = TextStyle(starSub.copy(0.4f), 13.sp) ) BasicText( - "Don't Ask Again", + stringResource(R.string.star_prompt_never), style = TextStyle(starSub.copy(0.7f), 13.sp, FontWeight.Medium, textAlign = TextAlign.Center), modifier = Modifier .clickable { diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/DocsBottomTabs.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/DocsBottomTabs.kt index dc8a076..e82f602 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/DocsBottomTabs.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/DocsBottomTabs.kt @@ -7,11 +7,13 @@ import androidx.compose.material.icons.rounded.GridView import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.chethan616.clearpdf.ui.theme.LiquidGlassColors import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.R import com.kyant.backdrop.Backdrop @Composable @@ -32,17 +34,17 @@ fun DocsBottomTabs( modifier = modifier ) { LiquidBottomTab(onClick = { onTabSelected(0) }) { - Icon(Icons.Rounded.Home, contentDescription = "Home", tint = tint, + Icon(Icons.Rounded.Home, contentDescription = stringResource(R.string.nav_home), tint = tint, modifier = Modifier.size(22.dp)) } LiquidBottomTab(onClick = { onTabSelected(1) }) { - Icon(Icons.Rounded.GridView, contentDescription = "Tools", tint = tint, + Icon(Icons.Rounded.GridView, contentDescription = stringResource(R.string.nav_tools), tint = tint, modifier = Modifier.size(22.dp)) } LiquidBottomTab(onClick = { onTabSelected(2) }) { - Icon(Icons.Rounded.Tune, contentDescription = "Settings", tint = tint, + Icon(Icons.Rounded.Tune, contentDescription = stringResource(R.string.nav_settings), tint = tint, modifier = Modifier.size(22.dp)) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidButton.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidButton.kt index bb29e4a..c61e844 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidButton.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidButton.kt @@ -34,6 +34,8 @@ import kotlin.math.tanh import androidx.compose.material3.Icon import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.res.stringResource +import com.chethan616.clearpdf.R private const val CLOSE_CROSS_SVG_PATH = "M480-424 284-228q-11 11-28 11t-28-11q-11-11-11-28t11-28l196-196-196-196q-11-11-11-28t11-28q11-11 28-11t28 11l196 196 196-196q11-11 28-11t28 11q11 11 11 28t-11 28L536-480l196 196q11 11 11 28t-11 28q-11 11-28 11t-28-11L480-424Z" @@ -55,7 +57,7 @@ fun CloseCrossIcon(modifier: Modifier = Modifier, tint: Color = Color.White) { fill = androidx.compose.ui.graphics.SolidColor(tint) ).build() } - Icon(vector, contentDescription = "Close", modifier = modifier, tint = tint) + Icon(vector, contentDescription = stringResource(R.string.close), modifier = modifier, tint = tint) } /** 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 e69acc0..4773c55 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 @@ -19,6 +19,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor 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 @@ -26,6 +27,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.chethan616.clearpdf.data.repository.SaveLocationManager +import com.chethan616.clearpdf.R import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.UISensor import com.kyant.backdrop.Backdrop @@ -59,7 +61,7 @@ fun LiquidSaveDialog( context.contentResolver.takePersistableUriPermission(uri, flags) } catch (_: Exception) {} locationUri = uri - locationDisplay = uri.lastPathSegment?.replace("primary:", "") ?: "Selected Folder" + locationDisplay = uri.lastPathSegment?.replace("primary:", "") ?: context.getString(R.string.selected_folder) } } @@ -75,13 +77,13 @@ fun LiquidSaveDialog( verticalArrangement = Arrangement.spacedBy(20.dp) ) { BasicText( - "Save Document", + stringResource(R.string.save_document), style = TextStyle(color = text, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) ) // File Name Input Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - BasicText("File Name", style = TextStyle(color = sub, fontSize = 13.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.file_name), style = TextStyle(color = sub, fontSize = 13.sp, fontWeight = FontWeight.Medium)) BasicTextField( value = fileName, onValueChange = { fileName = it }, @@ -94,7 +96,7 @@ fun LiquidSaveDialog( .padding(14.dp), decorationBox = { inner -> if (fileName.isEmpty()) { - BasicText("Document.pdf", style = TextStyle(color = sub.copy(0.5f), fontSize = 16.sp)) + BasicText(stringResource(R.string.document_pdf), style = TextStyle(color = sub.copy(0.5f), fontSize = 16.sp)) } inner() } @@ -103,7 +105,7 @@ fun LiquidSaveDialog( // Save Location Picker Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - BasicText("Save Location", style = TextStyle(color = sub, fontSize = 13.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.settings_save_location), style = TextStyle(color = sub, fontSize = 13.sp, fontWeight = FontWeight.Medium)) LiquidButton( onClick = { folderPicker.launch(null) }, backdrop = backdrop, @@ -119,7 +121,7 @@ fun LiquidSaveDialog( BasicText(locationDisplay, style = TextStyle(color = text, fontSize = 14.sp), maxLines = 1) } Spacer(Modifier.width(8.dp)) - Icon(Icons.Rounded.FolderOpen, "Change Folder", Modifier.size(20.dp), accent) + Icon(Icons.Rounded.FolderOpen, stringResource(R.string.change_folder), Modifier.size(20.dp), accent) } } } @@ -135,7 +137,7 @@ fun LiquidSaveDialog( backdrop = backdrop, surfaceColor = Color.Transparent ) { - BasicText("Cancel", style = TextStyle(color = sub, fontSize = 14.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.cancel), style = TextStyle(color = sub, fontSize = 14.sp, fontWeight = FontWeight.Medium)) } Spacer(Modifier.width(8.dp)) LiquidButton( @@ -147,8 +149,8 @@ fun LiquidSaveDialog( tint = accent ) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Rounded.Save, "Save", Modifier.size(16.dp), Color.White) - BasicText("Save", style = TextStyle(color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)) + 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)) } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/navigation/DocsNavGraph.kt b/app/src/main/java/com/chethan616/clearpdf/ui/navigation/DocsNavGraph.kt index 073a5d8..6006e67 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/navigation/DocsNavGraph.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/navigation/DocsNavGraph.kt @@ -23,6 +23,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import com.chethan616.clearpdf.ui.screen.CompressPdfScreen import com.chethan616.clearpdf.ui.screen.CreatePdfScreen +import com.chethan616.clearpdf.ui.screen.DecryptPdfScreen +import com.chethan616.clearpdf.ui.screen.EncryptPdfScreen import com.chethan616.clearpdf.ui.screen.ExtractTextScreen import com.chethan616.clearpdf.ui.screen.ImagesToPdfScreen import com.chethan616.clearpdf.ui.screen.HomeScreen @@ -57,6 +59,8 @@ private const val ROUTE_CREATE = "create_pdf" private const val ROUTE_ORGANIZE = "organize_pdf" private const val ROUTE_EXTRACT_TEXT = "extract_text" private const val ROUTE_IMAGES_TO_PDF = "images_to_pdf" +private const val ROUTE_DECRYPT_PDF = "decrypt_pdf" +private const val ROUTE_ENCRYPT_PDF = "encrypt_pdf" private const val ROUTE_ONBOARDING = "onboarding" private const val ARG_PDF_URI = "uri" private const val ROUTE_VIEWER = "$ROUTE_VIEWER_BASE?$ARG_PDF_URI={$ARG_PDF_URI}" @@ -100,6 +104,8 @@ fun DocsNavGraph( onDarkModeChanged: (Boolean) -> Unit, themeMode: Int, onThemeModeChanged: (Int) -> Unit, + selectedLocale: String, + onLocaleChanged: (String) -> Unit, incomingPdfUri: Uri? = null ) { val context = LocalContext.current @@ -194,6 +200,8 @@ fun DocsNavGraph( composable(ROUTE_ONBOARDING) { OnboardingScreen( backdrop = backdrop, + selectedLocale = selectedLocale, + onLanguageChanged = onLocaleChanged, onComplete = { navController.navigate(ROUTE_HOME) { popUpTo(ROUTE_ONBOARDING) { inclusive = true } @@ -230,7 +238,9 @@ fun DocsNavGraph( onNavigateToCreatePdf = { navController.navigate(ROUTE_CREATE) { launchSingleTop = true } }, onNavigateToOrganizePdf = { navController.navigate(ROUTE_ORGANIZE) { launchSingleTop = true } }, onNavigateToExtractText = { navController.navigate(ROUTE_EXTRACT_TEXT) { launchSingleTop = true } }, - onNavigateToImagesToPdf = { navController.navigate(ROUTE_IMAGES_TO_PDF) { 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 } } ) } @@ -240,7 +250,9 @@ fun DocsNavGraph( isDarkMode = isDarkMode, onDarkModeChanged = onDarkModeChanged, themeMode = themeMode, - onThemeModeChanged = onThemeModeChanged + onThemeModeChanged = onThemeModeChanged, + selectedLocale = selectedLocale, + onLocaleChanged = onLocaleChanged ) } @@ -255,6 +267,22 @@ fun DocsNavGraph( ) } + composable(ROUTE_DECRYPT_PDF) { + DecryptPdfScreen( + backdrop = backdrop, + onBack = { navController.popBackStack() }, + onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } + ) + } + + composable(ROUTE_ENCRYPT_PDF) { + EncryptPdfScreen( + backdrop = backdrop, + onBack = { navController.popBackStack() }, + onViewOutput = { uri -> navController.navigateToPdfViewer(uri) } + ) + } + composable( route = ROUTE_VIEWER, arguments = listOf( 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 a6876b5..056d2aa 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 @@ -1,5 +1,8 @@ package com.chethan616.clearpdf.ui.screen +import androidx.compose.ui.res.stringResource +import com.chethan616.clearpdf.R + import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -126,9 +129,9 @@ fun CompressPdfScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), text) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) } - LiquidGlassTopBar(title = "Compress PDF", backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f), titleFontSize = 18.sp) + LiquidGlassTopBar(title = stringResource(R.string.tool_compress), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f), titleFontSize = 18.sp) } // Scrollable Body Content @@ -152,8 +155,8 @@ fun CompressPdfScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Icon(Icons.Rounded.Compress, null, Modifier.size(56.dp), accent) - BasicText("Reduce File Size", style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) - BasicText("Compress your PDF while preserving quality", style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + BasicText(stringResource(R.string.tool_compress), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.tool_compress_sub), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) LiquidButton( onClick = { filePicker.launch(arrayOf("application/pdf")) }, @@ -161,7 +164,7 @@ fun CompressPdfScreen( ) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) - BasicText("Select PDF", style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) } } } @@ -176,7 +179,7 @@ fun CompressPdfScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { BasicText(state.sourceFileName, style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) - BasicText("Size: ${state.originalSizeBytes / 1024} KB", style = TextStyle(sub, 13.sp)) + BasicText(stringResource(R.string.size_kb, state.originalSizeBytes / 1024), style = TextStyle(sub, 13.sp)) } // Un-nested slider control for maximum performance (no glass card lag) @@ -196,7 +199,7 @@ fun CompressPdfScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon(Icons.Rounded.HighQuality, null, Modifier.size(22.dp), accent) - BasicText("Compression Quality", style = TextStyle(text, 16.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.settings_compression_quality), style = TextStyle(text, 16.sp, fontWeight = FontWeight.SemiBold)) } Box( Modifier @@ -223,8 +226,8 @@ fun CompressPdfScreen( ) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - BasicText("Smaller file", style = TextStyle(sub, 12.sp)) - BasicText("Better quality", style = TextStyle(sub, 12.sp)) + BasicText(stringResource(R.string.settings_smaller_size), style = TextStyle(sub, 12.sp)) + BasicText(stringResource(R.string.settings_higher_quality), style = TextStyle(sub, 12.sp)) } if (state.estimatedSizeBytes > 0) { @@ -273,7 +276,7 @@ fun CompressPdfScreen( tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth() ) { - BasicText("View PDF", style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold)) } } 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 cd30000..b52f0ee 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 @@ -1,5 +1,8 @@ package com.chethan616.clearpdf.ui.screen +import androidx.compose.ui.res.stringResource +import com.chethan616.clearpdf.R + import android.app.Activity import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult @@ -122,7 +125,7 @@ fun CreatePdfScreen( fun launchScanner() { if (activity == null) { - viewModel.setError("Scanner is unavailable in current context") + viewModel.setError(context.getString(R.string.scanner_unavailable)) return } val options = GmsDocumentScannerOptions.Builder() @@ -138,7 +141,7 @@ fun CreatePdfScreen( scannerLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) } .addOnFailureListener { error -> - viewModel.setError("Could not start scanner: ${error.message}") + viewModel.setError(context.getString(R.string.scanner_start_failed)) } } @@ -193,9 +196,9 @@ fun CreatePdfScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), text) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) } - LiquidGlassTopBar(title = "Create PDF", backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) + LiquidGlassTopBar(title = stringResource(R.string.tool_create), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) } Column( @@ -217,7 +220,7 @@ fun CreatePdfScreen( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Icon(Icons.AutoMirrored.Rounded.NoteAdd, null, Modifier.size(52.dp), accent) - BasicText("Create with scans, images, or blank pages", style = TextStyle(text, 19.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.create_pdf_subtitle), style = TextStyle(text, 19.sp, fontWeight = FontWeight.SemiBold)) BasicText( "Image-first creation is now default. Text is available as an advanced mode.", style = TextStyle(sub, 13.sp, textAlign = TextAlign.Center) @@ -232,9 +235,9 @@ fun CreatePdfScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { val templates = listOf( - Triple(CreateMode.FROM_IMAGES, "Import Images", Icons.Rounded.Image), - Triple(CreateMode.BLANK, "Blank Pages", Icons.Rounded.Description), - Triple(CreateMode.ADVANCED_TEXT, "Advanced Text", Icons.Rounded.Edit) + Triple(CreateMode.FROM_IMAGES, stringResource(R.string.create_mode_images), Icons.Rounded.Image), + Triple(CreateMode.BLANK, stringResource(R.string.create_mode_blank), Icons.Rounded.Description), + Triple(CreateMode.ADVANCED_TEXT, stringResource(R.string.create_mode_text), Icons.Rounded.Edit) ) templates.forEach { (mode, label, icon) -> @@ -291,7 +294,7 @@ fun CreatePdfScreen( modifier = Modifier.padding(vertical = 8.dp) ) { Icon(Icons.Rounded.CameraAlt, null, Modifier.size(17.dp), Color.White) - BasicText("Scan", style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.home_scan), style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) } } LiquidButton( @@ -305,7 +308,7 @@ fun CreatePdfScreen( modifier = Modifier.padding(vertical = 8.dp) ) { Icon(Icons.Rounded.UploadFile, null, Modifier.size(17.dp), text) - BasicText("Add Images", style = TextStyle(text, 14.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.images_add), style = TextStyle(text, 14.sp, FontWeight.Medium)) } } } @@ -318,7 +321,7 @@ fun CreatePdfScreen( .liquidGlassPanel(backdrop, uiSensor) .padding(16.dp) ) { - BasicText("Scan or add images to start your PDF draft.", style = TextStyle(sub, 14.sp)) + BasicText(stringResource(R.string.create_scan_or_images_hint), style = TextStyle(sub, 14.sp)) } } else { LazyColumn( @@ -338,7 +341,7 @@ fun CreatePdfScreen( ) { Image( painter = rememberAsyncImagePainter(uri), - contentDescription = "Page ${index + 1}", + contentDescription = stringResource(R.string.page_number, index + 1), contentScale = ContentScale.Crop, modifier = Modifier .size(width = 62.dp, height = 82.dp) @@ -346,8 +349,8 @@ fun CreatePdfScreen( ) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - BasicText("Page ${index + 1}", style = TextStyle(text, 14.sp, FontWeight.SemiBold)) - BasicText("Tap arrows to reorder", style = TextStyle(sub, 12.sp)) + BasicText(stringResource(R.string.create_page_label, index + 1), style = TextStyle(text, 14.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.create_reorder_hint), style = TextStyle(sub, 12.sp)) } Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -401,8 +404,8 @@ fun CreatePdfScreen( .padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { - BasicText("Create a clean blank PDF", style = TextStyle(text, 16.sp, FontWeight.SemiBold)) - BasicText("Set the number of pages and generate instantly.", style = TextStyle(sub, 13.sp)) + BasicText(stringResource(R.string.create_blank_title), style = TextStyle(text, 16.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.create_blank_subtitle), style = TextStyle(sub, 13.sp)) Row( verticalAlignment = Alignment.CenterVertically, @@ -411,7 +414,7 @@ fun CreatePdfScreen( LiquidButton(onClick = { viewModel.onBlankPageCountChanged(state.blankPageCount - 1) }, backdrop = backdrop) { BasicText("-", style = TextStyle(text, 18.sp, FontWeight.Bold)) } - BasicText("${state.blankPageCount} pages", style = TextStyle(text, 18.sp, FontWeight.Bold)) + BasicText(stringResource(R.string.create_pages, state.blankPageCount), style = TextStyle(text, 18.sp, FontWeight.Bold)) LiquidButton(onClick = { viewModel.onBlankPageCountChanged(state.blankPageCount + 1) }, backdrop = backdrop, tint = accent) { BasicText("+", style = TextStyle(Color.White, 18.sp, FontWeight.Bold)) } @@ -428,8 +431,8 @@ fun CreatePdfScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - BasicText("Advanced text to PDF", style = TextStyle(text, 16.sp, FontWeight.SemiBold)) - BasicText("Use this for quick notes. For polished docs, prefer scan/image mode.", style = TextStyle(sub, 12.sp)) + BasicText(stringResource(R.string.create_text_title), style = TextStyle(text, 16.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.create_text_subtitle), style = TextStyle(sub, 12.sp)) BasicTextField( value = state.textContent, onValueChange = { viewModel.onTextChanged(it) }, @@ -443,20 +446,20 @@ fun CreatePdfScreen( .padding(12.dp), decorationBox = { inner -> if (state.textContent.isEmpty()) { - BasicText("Type text content...", style = TextStyle(sub.copy(alpha = 0.55f), 14.sp)) + BasicText(stringResource(R.string.create_text_hint), style = TextStyle(sub.copy(alpha = 0.55f), 14.sp)) } inner() } ) - BasicText("${state.textContent.length} characters", style = TextStyle(sub, 12.sp)) + BasicText(stringResource(R.string.create_character_count, state.textContent.length), style = TextStyle(sub, 12.sp)) } } } val createLabel = when (state.selectedMode) { - CreateMode.FROM_IMAGES -> "Create PDF from Draft" - CreateMode.BLANK -> "Create Blank PDF" - CreateMode.ADVANCED_TEXT -> "Create Text PDF" + CreateMode.FROM_IMAGES -> stringResource(R.string.create_from_draft) + CreateMode.BLANK -> stringResource(R.string.create_blank_pdf) + CreateMode.ADVANCED_TEXT -> stringResource(R.string.create_text_pdf) } LiquidButton( @@ -501,7 +504,7 @@ fun CreatePdfScreen( tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth() ) { - BasicText("View PDF", style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold)) } } 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 new file mode 100644 index 0000000..2f34c50 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt @@ -0,0 +1,210 @@ +package com.chethan616.clearpdf.ui.screen + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.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.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.LockOpen +import androidx.compose.material.icons.rounded.OpenInNew +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.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.LiquidGlassTopBar +import com.chethan616.clearpdf.ui.components.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.pdfcore.security.PdfSecurityService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun DecryptPdfScreen( + backdrop: LayerBackdrop, + onBack: () -> Unit, + onViewOutput: (android.net.Uri) -> Unit +) { + val context = LocalContext.current + val isDark = LocalIsDarkMode.current + val text = if (isDark) Color(0xFFF0F0F0) else Color(0xFF222222) + val sub = if (isDark) Color(0xFFAAAAAA) else Color(0xFF777777) + val accent = Color(0xFF7C4DFF) + val uiSensor = rememberUISensor() + val scope = rememberCoroutineScope() + var sourceUri by remember { mutableStateOf(null) } + var outputUri by remember { mutableStateOf(null) } + var password by remember { mutableStateOf("") } + var isBusy by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + var isError by remember { mutableStateOf(false) } + + val saveLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/pdf") + ) { destination -> + val source = sourceUri ?: return@rememberLauncherForActivityResult + if (destination == null) return@rememberLauncherForActivityResult + isBusy = true + message = null + scope.launch { + try { + withContext(Dispatchers.IO) { + PdfSecurityService.decryptToUri(context, source, destination, password) + } + isError = false + outputUri = destination + message = context.getString(R.string.decrypt_pdf_success) + } catch (_: PdfSecurityService.PasswordRequiredException) { + isError = true + message = context.getString(R.string.decrypt_pdf_invalid) + } catch (_: Exception) { + isError = true + message = context.getString(R.string.file_preview_unsupported) + } finally { + isBusy = false + } + } + } + + val openLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + sourceUri = uri + outputUri = null + message = null + password = "" + } + + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + 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, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Icon(Icons.Rounded.LockOpen, null, Modifier.size(52.dp), accent) + BasicText(stringResource(R.string.decrypt_pdf_title), style = TextStyle(text, 21.sp, FontWeight.SemiBold)) + BasicText( + stringResource(R.string.decrypt_pdf_subtitle), + style = TextStyle(sub, 14.sp), + modifier = Modifier.padding(horizontal = 10.dp) + ) + LiquidButton( + onClick = { openLauncher.launch(arrayOf("application/pdf")) }, + backdrop = backdrop, + tint = accent, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.decrypt_pdf_select), style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) + } + + sourceUri?.let { source -> + BasicText( + source.lastPathSegment ?: stringResource(R.string.viewer_title), + style = TextStyle(text, 14.sp, FontWeight.Medium), + maxLines = 1 + ) + LiquidButton( + onClick = { onViewOutput(source) }, + backdrop = backdrop, + surfaceColor = Color.White.copy(0.08f), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Rounded.OpenInNew, null, Modifier.size(17.dp), accent) + BasicText(stringResource(R.string.view_pdf), style = TextStyle(text, 14.sp, FontWeight.Medium)) + } + BasicTextField( + value = password, + onValueChange = { password = it }, + 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 (password.isEmpty()) BasicText(stringResource(R.string.decrypt_pdf_password), style = TextStyle(sub, 14.sp)) + inner() + } + ) + LiquidButton( + onClick = { saveLauncher.launch("unlocked.pdf") }, + backdrop = backdrop, + tint = accent, + modifier = Modifier.fillMaxWidth() + ) { + if (isBusy) CircularProgressIndicator(Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp) + else BasicText(stringResource(R.string.decrypt_pdf_output), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + } + } + + AnimatedVisibility(visible = message != null, enter = fadeIn(), exit = fadeOut()) { + message?.let { BasicText(it, style = TextStyle(if (isError) Color(0xFFD32F2F) else Color(0xFF2E7D32), 13.sp)) } + } + outputUri?.let { result -> + LiquidButton( + onClick = { onViewOutput(result) }, + backdrop = backdrop, + tint = Color(0xFF2E7D32), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Rounded.OpenInNew, null, Modifier.size(17.dp), Color.White) + BasicText(stringResource(R.string.view_unlocked_pdf), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + } + } + } + androidx.compose.foundation.layout.Spacer( + 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 new file mode 100644 index 0000000..930ec5b --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt @@ -0,0 +1,268 @@ +package com.chethan616.clearpdf.ui.screen + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.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.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.Lock +import androidx.compose.material.icons.rounded.OpenInNew +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.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.PasswordVisualTransformation +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.liquidGlassPanel +import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode +import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.pdfcore.security.PdfSecurityService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun EncryptPdfScreen( + backdrop: LayerBackdrop, + onBack: () -> Unit, + onViewOutput: (android.net.Uri) -> Unit +) { + val context = LocalContext.current + val isDark = LocalIsDarkMode.current + val text = if (isDark) Color(0xFFF0F0F0) else Color(0xFF222222) + val sub = if (isDark) Color(0xFFAAAAAA) else Color(0xFF777777) + val accent = Color(0xFF5E35B1) + val uiSensor = rememberUISensor() + val scope = rememberCoroutineScope() + var sourceUri by remember { mutableStateOf(null) } + var outputUri by remember { mutableStateOf(null) } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var isBusy by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + var isError by remember { mutableStateOf(false) } + + val saveLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/pdf") + ) { destination -> + val source = sourceUri ?: return@rememberLauncherForActivityResult + if (destination == null) return@rememberLauncherForActivityResult + if (password.isBlank()) { + isError = true + message = context.getString(R.string.encrypt_pdf_password_required) + return@rememberLauncherForActivityResult + } + if (password != confirmPassword) { + isError = true + message = context.getString(R.string.encrypt_pdf_mismatch) + return@rememberLauncherForActivityResult + } + isBusy = true + message = null + scope.launch { + try { + withContext(Dispatchers.IO) { + PdfSecurityService.encryptToUri(context, source, destination, password) + } + outputUri = destination + isError = false + message = context.getString(R.string.encrypt_pdf_success) + } catch (_: PdfSecurityService.PasswordRequiredException) { + isError = true + message = context.getString(R.string.encrypt_pdf_source_protected) + } catch (_: Exception) { + isError = true + message = context.getString(R.string.file_preview_unsupported) + } finally { + isBusy = false + } + } + } + + val openLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + sourceUri = uri + outputUri = null + message = null + password = "" + confirmPassword = "" + } + + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + 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 + .fillMaxWidth() + .liquidGlassPanel(backdrop, uiSensor) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Icon(Icons.Rounded.Lock, null, Modifier.size(52.dp), accent) + BasicText(stringResource(R.string.encrypt_pdf_title), style = TextStyle(text, 21.sp, FontWeight.SemiBold)) + BasicText( + stringResource(R.string.encrypt_pdf_subtitle), + style = TextStyle(sub, 14.sp), + modifier = Modifier.padding(horizontal = 10.dp) + ) + LiquidButton( + onClick = { openLauncher.launch(arrayOf("application/pdf")) }, + backdrop = backdrop, + tint = accent, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) + BasicText(stringResource(R.string.encrypt_pdf_select), style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) + } + + sourceUri?.let { source -> + BasicText( + source.lastPathSegment ?: stringResource(R.string.viewer_title), + style = TextStyle(text, 14.sp, FontWeight.Medium), + maxLines = 1 + ) + LiquidButton( + onClick = { onViewOutput(source) }, + backdrop = backdrop, + surfaceColor = Color.White.copy(0.08f), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Rounded.OpenInNew, null, Modifier.size(17.dp), accent) + BasicText(stringResource(R.string.view_pdf), style = TextStyle(text, 14.sp, FontWeight.Medium)) + } + SecureInput( + value = password, + placeholder = stringResource(R.string.encrypt_pdf_password), + text = text, + sub = sub, + isDark = isDark, + onValueChange = { password = it } + ) + SecureInput( + value = confirmPassword, + placeholder = stringResource(R.string.encrypt_pdf_confirm), + text = text, + sub = sub, + isDark = isDark, + onValueChange = { confirmPassword = it } + ) + LiquidButton( + onClick = { + when { + password.isBlank() -> { + isError = true + message = context.getString(R.string.encrypt_pdf_password_required) + } + password != confirmPassword -> { + isError = true + message = context.getString(R.string.encrypt_pdf_mismatch) + } + else -> saveLauncher.launch("encrypted.pdf") + } + }, + backdrop = backdrop, + tint = if (password.isNotBlank() && password == confirmPassword) accent else Color.White.copy(0.10f), + modifier = Modifier.fillMaxWidth() + ) { + if (isBusy) CircularProgressIndicator(Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp) + else BasicText(stringResource(R.string.encrypt_pdf_output), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + } + } + + AnimatedVisibility(visible = message != null, enter = fadeIn(), exit = fadeOut()) { + message?.let { BasicText(it, style = TextStyle(if (isError) Color(0xFFD32F2F) else Color(0xFF2E7D32), 13.sp)) } + } + + outputUri?.let { result -> + LiquidButton( + onClick = { onViewOutput(result) }, + backdrop = backdrop, + tint = Color(0xFF2E7D32), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Rounded.OpenInNew, null, Modifier.size(17.dp), Color.White) + BasicText(stringResource(R.string.view_encrypted_pdf), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + } + } + } + androidx.compose.foundation.layout.Spacer( + Modifier.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 68.dp) + ) + } +} + +@Composable +private fun SecureInput( + value: String, + placeholder: String, + text: Color, + sub: Color, + isDark: Boolean, + onValueChange: (String) -> Unit +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = TextStyle(text, 15.sp), + visualTransformation = PasswordVisualTransformation(), + 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 (value.isEmpty()) BasicText(placeholder, style = TextStyle(sub, 14.sp)) + inner() + } + ) +} 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 0e535cf..ab14c86 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 @@ -1,5 +1,8 @@ package com.chethan616.clearpdf.ui.screen +import androidx.compose.ui.res.stringResource +import com.chethan616.clearpdf.R + import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -110,9 +113,9 @@ fun ExtractTextScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), text) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) } - LiquidGlassTopBar("Extract Text", backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) + LiquidGlassTopBar(stringResource(R.string.tool_extract), backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) } Column( @@ -137,12 +140,12 @@ fun ExtractTextScreen( modifier = Modifier.weight(1f) ) { Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) - BasicText("Pick a PDF", style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) } if (state.text.isNotEmpty()) { LiquidButton(onClick = { clipboard.setText(AnnotatedString(state.text)) }, backdrop = backdrop) { Icon(Icons.Rounded.ContentCopy, null, Modifier.size(16.dp), text) - BasicText("Copy", style = TextStyle(text, 14.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.copy), style = TextStyle(text, 14.sp, FontWeight.Medium)) } com.chethan616.clearpdf.ui.components.LiquidIconButton( onClick = { @@ -150,13 +153,13 @@ fun ExtractTextScreen( type = "text/plain" putExtra(Intent.EXTRA_TEXT, state.text) } - context.startActivity(Intent.createChooser(send, "Share text")) + context.startActivity(Intent.createChooser(send, context.getString(R.string.extract_share_text))) }, backdrop = backdrop, tint = Color(0xFF0088FF), modifier = Modifier.size(44.dp) ) { - Icon(Icons.Rounded.Share, "Share", Modifier.size(20.dp), Color.White) + Icon(Icons.Rounded.Share, stringResource(R.string.share), Modifier.size(20.dp), Color.White) } } } @@ -175,10 +178,10 @@ fun ExtractTextScreen( BasicText(state.text, style = TextStyle(text, 14.sp)) } state.hasResult -> Box(Modifier.fillMaxSize(), Alignment.Center) { - BasicText("No text layer in this PDF.", style = TextStyle(sub, 13.sp)) + BasicText(stringResource(R.string.extract_no_text), style = TextStyle(sub, 13.sp)) } else -> Box(Modifier.fillMaxSize(), Alignment.Center) { - BasicText("Pick a PDF to pull its text.", style = TextStyle(sub, 13.sp)) + BasicText(stringResource(R.string.extract_pick_hint), style = TextStyle(sub, 13.sp)) } } } 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 0cb170d..d875586 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 @@ -4,7 +4,10 @@ import android.content.Intent import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -59,7 +62,6 @@ 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.CompositingStrategy import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathParser @@ -119,48 +121,32 @@ fun HomeScreen( var infoRecent by remember { mutableStateOf(null) } val lifecycleOwner = LocalLifecycleOwner.current - var b1Visible by remember { mutableStateOf(false) } - var b2Visible by remember { mutableStateOf(false) } - var b3Visible by remember { mutableStateOf(false) } - var b4Visible by remember { mutableStateOf(false) } - - val morphProgress by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (selectedRecent != null) 1f else 0f, - animationSpec = spring( - dampingRatio = 0.68f, - stiffness = 180f - ), - label = "morphProgress" + val popupTransition = updateTransition( + targetState = selectedRecent != null, + label = "recentPopupTransition" ) - - androidx.compose.runtime.LaunchedEffect(selectedRecent) { - if (selectedRecent != null) { - b1Visible = false - b2Visible = false - b3Visible = false - b4Visible = false - // Wait for pop-up container to morph open gracefully (~220ms) - kotlinx.coroutines.delay(220L) - b1Visible = true - kotlinx.coroutines.delay(65L) - b2Visible = true - kotlinx.coroutines.delay(65L) - b3Visible = true - kotlinx.coroutines.delay(65L) - b4Visible = true - } else { - b1Visible = false - b2Visible = false - b3Visible = false - b4Visible = false - } - } - - val btnSpring = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium) - val b1Scale by androidx.compose.animation.core.animateFloatAsState(if (b1Visible) 1f else 0f, btnSpring, label = "b1") - val b2Scale by androidx.compose.animation.core.animateFloatAsState(if (b2Visible) 1f else 0f, btnSpring, label = "b2") - val b3Scale by androidx.compose.animation.core.animateFloatAsState(if (b3Visible) 1f else 0f, btnSpring, label = "b3") - val b4Scale by androidx.compose.animation.core.animateFloatAsState(if (b4Visible) 1f else 0f, btnSpring, label = "b4") + val morphProgress by popupTransition.animateFloat( + 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" + ) { if (it) 1f else 0f } DisposableEffect(lifecycleOwner, context) { val observer = LifecycleEventObserver { _, event -> @@ -420,7 +406,7 @@ fun HomeScreen( ) val timeStr = formatTimestamp(recent.timestamp) val sizeStr = if (recent.sizeBytes > 0) " · ${formatFileSize(recent.sizeBytes)}" else "" - val pageStr = if (recent.pageCount > 0) " · ${recent.pageCount} pg" 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)) } @@ -430,13 +416,13 @@ fun HomeScreen( .background(accent.copy(0.12f)) .padding(horizontal = 6.dp, vertical = 2.dp) ) { - BasicText("PDF", style = TextStyle(accent, 9.sp, FontWeight.Bold)) + BasicText(stringResource(R.string.recents_pdf_type), style = TextStyle(accent, 9.sp, FontWeight.Bold)) } } } if (recents.size > homeRecentLimit && !showAllRecents) { BasicText( - "Long press for quick actions", + 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) ) @@ -480,7 +466,6 @@ fun HomeScreen( alpha = morphProgress.coerceIn(0f, 1f) transformOrigin = TransformOrigin(0.5f, 0.85f) shadowElevation = (16f * morphProgress).dp.toPx() - compositingStrategy = CompositingStrategy.Offscreen } .liquidGlassPanel(backdrop, uiSensor) .clickable( @@ -559,7 +544,7 @@ fun HomeScreen( ) { Icon(Icons.Rounded.FileOpen, null, Modifier.size(22.dp), Color.White) } - BasicText("Open", style = TextStyle(text, 11.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.recents_open), style = TextStyle(text, 11.sp, FontWeight.Medium)) } // 2. Share Button (Green) @@ -580,7 +565,7 @@ fun HomeScreen( putExtra(Intent.EXTRA_STREAM, recent.uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - context.startActivity(Intent.createChooser(shareIntent, "Share PDF")) + context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.recents_share_pdf))) }, backdrop = backdrop, tint = Color(0xFF4CAF50), @@ -588,7 +573,7 @@ fun HomeScreen( ) { Icon(Icons.Rounded.Share, null, Modifier.size(22.dp), Color.White) } - BasicText("Share", style = TextStyle(text, 11.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.recents_share), style = TextStyle(text, 11.sp, FontWeight.Medium)) } // 3. Info Button (Purple) @@ -612,7 +597,7 @@ fun HomeScreen( ) { Icon(Icons.Rounded.Info, null, Modifier.size(22.dp), Color.White) } - BasicText("Details", style = TextStyle(text, 11.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.recents_details), style = TextStyle(text, 11.sp, FontWeight.Medium)) } // 4. Remove Button (Red) @@ -637,7 +622,7 @@ fun HomeScreen( ) { CloseCrossIcon(Modifier.size(18.dp), Color.White) } - BasicText("Remove", style = TextStyle(redAccent, 11.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.recents_remove), style = TextStyle(redAccent, 11.sp, FontWeight.Medium)) } } } @@ -685,7 +670,7 @@ fun HomeScreen( } Column(Modifier.weight(1f)) { BasicText( - "File Info", + stringResource(R.string.recents_file_info), style = TextStyle(text, 18.sp, FontWeight.SemiBold) ) BasicText( @@ -704,10 +689,10 @@ fun HomeScreen( .background(if (isLight) Color.Black.copy(0.06f) else Color.White.copy(0.08f)) ) - InfoRow("Pages", if (recent.pageCount > 0) recent.pageCount.toString() else "Unknown", text, sub) - InfoRow("Size", if (recent.sizeBytes > 0) formatFileSize(recent.sizeBytes) else "Unknown", text, sub) - InfoRow("Added", formatTimestamp(recent.timestamp), text, sub) - InfoRow("Location", recent.uri.toString(), text, sub, maxLines = 3) + InfoRow(stringResource(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) LiquidButton( onClick = { infoRecent = null }, @@ -715,7 +700,7 @@ fun HomeScreen( tint = accent, modifier = Modifier.fillMaxWidth() ) { - BasicText("Done", style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.done), style = TextStyle(Color.White, 14.sp, FontWeight.SemiBold)) } } } @@ -765,20 +750,22 @@ private fun ActionSheetItem( } } +@Composable private fun formatTimestamp(ts: Long): String { val now = System.currentTimeMillis() val diff = now - ts return when { - diff < 60_000 -> "Just now" - diff < 3_600_000 -> "${diff / 60_000} min ago" - diff < 86_400_000 -> "${diff / 3_600_000}h ago" - diff < 172_800_000 -> "Yesterday" + diff < 60_000 -> stringResource(R.string.recents_just_now) + diff < 3_600_000 -> stringResource(R.string.recents_minutes_ago, diff / 60_000) + diff < 86_400_000 -> stringResource(R.string.recents_hours_ago, diff / 3_600_000) + diff < 172_800_000 -> stringResource(R.string.recents_yesterday) else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(Date(ts)) } } +@Composable private fun formatFileSize(bytes: Long): String = when { - bytes < 1024 -> "$bytes B" - bytes < 1024 * 1024 -> "${bytes / 1024} KB" - else -> "${"%.1f".format(bytes / (1024.0 * 1024.0))} MB" + bytes < 1024 -> stringResource(R.string.recents_size_bytes, bytes) + bytes < 1024 * 1024 -> stringResource(R.string.recents_size_kb, bytes / 1024) + else -> stringResource(R.string.recents_size_mb, bytes / (1024.0 * 1024.0)) } 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 421c62c..dd1dc7f 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 @@ -1,5 +1,8 @@ package com.chethan616.clearpdf.ui.screen +import androidx.compose.ui.res.stringResource +import com.chethan616.clearpdf.R + import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -116,9 +119,9 @@ fun ImagesToPdfScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), text) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) } - LiquidGlassTopBar("Images → PDF", backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) + LiquidGlassTopBar(stringResource(R.string.images_to_pdf_title), backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) } Column( @@ -134,11 +137,11 @@ fun ImagesToPdfScreen( Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { LiquidButton(onClick = { picker.launch("image/*") }, backdrop = backdrop, tint = accent) { Icon(Icons.Rounded.AddPhotoAlternate, null, Modifier.size(18.dp), Color.White) - BasicText("Add images", style = TextStyle(Color.White, 14.sp, FontWeight.Medium)) + 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("Clear", style = TextStyle(text, 14.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.viewer_clear), style = TextStyle(text, 14.sp, FontWeight.Medium)) } } } @@ -148,17 +151,17 @@ fun ImagesToPdfScreen( LiquidButton( onClick = { viewModel.setFitToA4(true) }, backdrop = backdrop, surfaceColor = if (state.fitToA4) accent.copy(0.30f) else Color.White.copy(0.06f) - ) { BasicText("Fit to A4", style = TextStyle(text, 13.sp, FontWeight.Medium)) } + ) { BasicText(stringResource(R.string.images_fit_a4), style = TextStyle(text, 13.sp, FontWeight.Medium)) } LiquidButton( onClick = { viewModel.setFitToA4(false) }, backdrop = backdrop, surfaceColor = if (!state.fitToA4) accent.copy(0.30f) else Color.White.copy(0.06f) - ) { BasicText("Original size", style = TextStyle(text, 13.sp, FontWeight.Medium)) } + ) { BasicText(stringResource(R.string.images_original_size), style = TextStyle(text, 13.sp, FontWeight.Medium)) } } Box(Modifier.fillMaxWidth().weight(1f)) { if (state.imageUris.isEmpty()) { Box(Modifier.fillMaxSize(), Alignment.Center) { - BasicText("Add photos to build a PDF.", style = TextStyle(sub, 13.sp)) + BasicText(stringResource(R.string.images_hint), style = TextStyle(sub, 13.sp)) } } else { LazyVerticalGrid( @@ -171,7 +174,7 @@ fun ImagesToPdfScreen( Box(Modifier.aspectRatio(0.75f).clip(RoundedCornerShape(8.dp)).background(Color.White.copy(0.06f))) { AsyncImage( model = uri, - contentDescription = "Image ${index + 1}", + contentDescription = stringResource(R.string.image_number, index + 1), contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) @@ -181,7 +184,7 @@ fun ImagesToPdfScreen( .clickable { viewModel.removeImage(index) }, Alignment.Center ) { - Icon(Icons.Rounded.Close, "Remove", Modifier.size(14.dp), Color.White) + Icon(Icons.Rounded.Close, stringResource(R.string.delete), Modifier.size(14.dp), Color.White) } } } @@ -196,7 +199,7 @@ fun ImagesToPdfScreen( BasicText(msg, style = TextStyle(Color(0xFFB9F6CA), 12.sp), modifier = Modifier.weight(1f)) state.lastOutputUri?.let { uri -> LiquidButton(onClick = { onViewOutput(uri) }, backdrop = backdrop, tint = accent) { - BasicText("Open", style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.open), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } } } 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 16b2264..ee7ef7c 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 @@ -1,5 +1,8 @@ package com.chethan616.clearpdf.ui.screen +import androidx.compose.ui.res.stringResource +import com.chethan616.clearpdf.R + import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -131,9 +134,9 @@ fun MergePdfScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), text) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) } - LiquidGlassTopBar(title = "Merge PDFs", backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) + LiquidGlassTopBar(title = stringResource(R.string.tool_merge), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) } Column( @@ -155,8 +158,8 @@ fun MergePdfScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Icon(Icons.AutoMirrored.Rounded.CallMerge, null, Modifier.size(56.dp), accent) - BasicText("Combine Multiple PDFs", style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) - BasicText("Select two or more PDF files to merge into one", style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) + BasicText(stringResource(R.string.tool_merge), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.tool_merge_description), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center)) LiquidButton( onClick = { filePicker.launch(arrayOf("application/pdf")) }, @@ -164,7 +167,7 @@ fun MergePdfScreen( ) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Rounded.AddCircleOutline, null, Modifier.size(18.dp), Color.White) - BasicText("Add Files", style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.add_files), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) } } } @@ -177,8 +180,8 @@ fun MergePdfScreen( .padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - BasicText("${state.selectedFiles.size} files selected", style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) - BasicText("Reorder files before merging. Top to bottom = output order.", style = TextStyle(sub, 12.sp)) + BasicText(stringResource(R.string.files_selected, state.selectedFiles.size), style = TextStyle(text, 15.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.reorder_files_hint), style = TextStyle(sub, 12.sp)) state.selectedFiles.forEachIndexed { index, file -> Row( Modifier.fillMaxWidth(), @@ -215,7 +218,7 @@ fun MergePdfScreen( if (state.selectedFiles.size < 2) { Column(Modifier.fillMaxWidth().liquidGlassPanel(backdrop, uiSensor).padding(14.dp)) { - BasicText("Select at least 2 PDFs to continue.", style = TextStyle(Color(0xFFD32F2F), 13.sp)) + BasicText(stringResource(R.string.select_two_pdfs), style = TextStyle(Color(0xFFD32F2F), 13.sp)) } } @@ -257,7 +260,7 @@ fun MergePdfScreen( tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth() ) { - BasicText("View PDF", style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.SemiBold)) } } 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 eaf133b..031b1b7 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,22 +1,23 @@ package com.chethan616.clearpdf.ui.screen -import androidx.compose.ui.res.stringResource -import com.chethan616.clearpdf.R -import androidx.appcompat.app.AppCompatDelegate import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState +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.spring +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.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,30 +32,25 @@ 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.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.AutoStories import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.Draw -import androidx.compose.material.icons.rounded.Folder -import androidx.compose.material.icons.rounded.Image +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.TableChart -import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -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 @@ -64,13 +60,15 @@ 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.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.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.os.LocaleListCompat +import com.chethan616.clearpdf.R import com.chethan616.clearpdf.data.repository.OnboardingManager import com.chethan616.clearpdf.ui.components.LiquidButton import com.chethan616.clearpdf.ui.components.liquidGlassPanel @@ -78,193 +76,214 @@ 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 kotlinx.coroutines.delay -import kotlinx.coroutines.launch -private data class Language( - val name: String, - val nativeName: String, +private data class LanguageOption( val code: String, - val flag: String + val badge: String, + val labelRes: Int ) private val SUPPORTED_LANGUAGES = listOf( - Language("English", "English", "en", "🇬🇧"), - Language("Portuguese (Brazil)", "Português (Brasil)", "pt-BR", "🇧🇷") + LanguageOption("en", "EN", R.string.language_english), + LanguageOption("pt-BR", "BR", R.string.language_portuguese) ) -private data class FileTypeItem( - val label: String, - val resId: Int +private data class FormatOption( + val labelRes: Int, + val drawableRes: Int ) -private val FILE_TYPES = listOf( - FileTypeItem("PDF", R.drawable.ic_format_pdf), - FileTypeItem("Word", R.drawable.ic_format_word), - FileTypeItem("Excel", R.drawable.ic_format_excel), - FileTypeItem("PowerPoint", R.drawable.ic_format_ppt), - FileTypeItem("Images", R.drawable.ic_format_image), - FileTypeItem("TXT", R.drawable.ic_format_txt) +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 FeatureItem( +private data class FeatureOption( val icon: ImageVector, - val color: Color, + val accent: Color, val titleRes: Int, - val descRes: Int + val descriptionRes: Int ) -private val FEATURES = listOf( - FeatureItem(Icons.Rounded.Draw, Color(0xFF7E57C2), com.chethan616.clearpdf.R.string.feature_annotate_title, com.chethan616.clearpdf.R.string.feature_annotate_desc), - FeatureItem(Icons.Rounded.Search, Color(0xFF0288D1), com.chethan616.clearpdf.R.string.feature_search_title, com.chethan616.clearpdf.R.string.feature_search_desc), - FeatureItem(Icons.Rounded.Tune, Color(0xFF00897B), com.chethan616.clearpdf.R.string.feature_tools_title, com.chethan616.clearpdf.R.string.feature_tools_desc) +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) ) -/** - * Apple HIG-inspired, 4-page onboarding experience. - * Page 0: Language selector - * Page 1: File compatibility showcase - * Page 2: Feature highlights - * Page 3: "Let's Go" CTA - */ +/** A quiet, editorial onboarding flow for a private document workspace. */ @Composable fun OnboardingScreen( backdrop: LayerBackdrop, - onComplete: () -> Unit + onComplete: () -> Unit, + selectedLocale: String = "en", + onLanguageChanged: (String) -> Unit = {} ) { val context = LocalContext.current - val isDarkMode = LocalIsDarkMode.current + val isDark = LocalIsDarkMode.current val uiSensor = rememberUISensor() - val scope = rememberCoroutineScope() - - var currentPage by remember { mutableIntStateOf(0) } - var selectedLocale by remember { - mutableStateOf(OnboardingManager.getSelectedLocale(context)) - } - - val totalPages = 4 - - // Page entry animation - var contentVisible by remember { mutableStateOf(false) } - LaunchedEffect(currentPage) { - contentVisible = false - delay(80) - contentVisible = true - } - - val bgBrush = if (isDarkMode) { - Brush.radialGradient( - colors = listOf(Color(0xFF0D1117), Color(0xFF0A0A0F)), - radius = 2000f - ) + 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.radialGradient( - colors = listOf(Color(0xFFF5F7FF), Color(0xFFECEFF5)), - radius = 2000f - ) - } - - val pageColor = when (currentPage) { - 0 -> Color(0xFF6366F1) // Indigo - 1 -> Color(0xFF00B0FF) // Bright Cyan - 2 -> Color(0xFF8B5CF6) // Purple - else -> Color(0xFF00C853) // Vivid Emerald + Brush.linearGradient(listOf(Color(0xFFF5F8FF), Color(0xFFE9EEFA))) } Box( Modifier .fillMaxSize() - .background(bgBrush) + .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) + ) + Column( Modifier .fillMaxSize() - .padding(horizontal = 24.dp, vertical = 20.dp), + .padding(horizontal = 22.dp, vertical = 18.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Page dots indicator Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(bottom = 32.dp) + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - repeat(totalPages) { page -> - val isActive = page == currentPage - val dotWidth by animateDpAsState( - if (isActive) 28.dp else 8.dp, - spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessMedium), - label = "dotWidth$page" - ) - val dotAlpha by animateFloatAsState( - if (isActive) 1f else 0.3f, - tween(200), label = "dotAlpha$page" + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + 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 - .width(dotWidth) - .height(8.dp) + .weight(if (selected) width else 0.32f) + .height(4.dp) .clip(CircleShape) - .graphicsLayer { alpha = dotAlpha } - .background(pageColor) + .background(if (selected) accent else secondary.copy(alpha = 0.18f)) ) } } - // Animated page content AnimatedContent( - targetState = currentPage, + targetState = page, transitionSpec = { - if (targetState > initialState) { - (slideInHorizontally { it / 2 } + fadeIn(tween(280))).togetherWith( - slideOutHorizontally { -it / 2 } + fadeOut(tween(180)) - ) + 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 / 2 } + fadeIn(tween(280))).togetherWith( - slideOutHorizontally { it / 2 } + fadeOut(tween(180)) - ) + (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), - label = "pageContent" - ) { page -> - when (page) { + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + label = "onboardingPage" + ) { currentPage -> + when (currentPage) { 0 -> LanguagePage( backdrop = backdrop, uiSensor = uiSensor, + isDark = isDark, + text = text, + secondary = secondary, selectedLocale = selectedLocale, - isDark = isDarkMode, - onLanguageSelected = { code -> - selectedLocale = code - OnboardingManager.setSelectedLocale(context, code) - com.chethan616.clearpdf.ui.utils.LocaleHelper.applyLocale(context, code, recreate = false) - } + onLanguageSelected = onLanguageChanged ) - 1 -> FilesPage(isDark = isDarkMode) - 2 -> FeaturesPage(backdrop = backdrop, uiSensor = uiSensor, isDark = isDarkMode) - 3 -> ReadyPage(isDark = isDarkMode) + 1 -> FormatsPage(backdrop, uiSensor, isDark, text, secondary) + 2 -> FeaturesPage(backdrop, uiSensor, isDark, text, secondary) + else -> ReadyPage(backdrop, uiSensor, isDark, text, secondary) } } - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(14.dp)) - // Navigation button + // Keep the proven Backdrop button as the only primary action. LiquidButton( onClick = { - if (currentPage < totalPages - 1) { - currentPage++ - } else { + if (page < pageCount - 1) page++ + else { OnboardingManager.setOnboardingComplete(context) onComplete() } }, backdrop = backdrop, - tint = pageColor, + tint = accent, modifier = Modifier.fillMaxWidth() ) { BasicText( - if (currentPage == totalPages - 1) stringResource(R.string.onboarding_cta_get_started) else stringResource(R.string.onboarding_cta_continue), - style = TextStyle(Color.White, 17.sp, FontWeight.SemiBold), + 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) ) } @@ -272,86 +291,88 @@ fun OnboardingScreen( } } -// ─── Page 0: Language Selector ──────────────────────────────────────────────── - @Composable private fun LanguagePage( backdrop: LayerBackdrop, uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - selectedLocale: String, isDark: Boolean, + text: Color, + secondary: Color, + selectedLocale: String, onLanguageSelected: (String) -> Unit ) { - val text = if (isDark) Color(0xFFF0F0F0) else Color(0xFF1A1A2E) - val sub = if (isDark) Color(0xFFAAAAAA) else Color(0xFF666699) - Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Hero icon - Box( - Modifier - .size(100.dp) - .clip(RoundedCornerShape(30.dp)) - .background(LiquidGlassColors.Blue.copy(alpha = 0.15f)) - .border(1.dp, LiquidGlassColors.Blue.copy(alpha = 0.3f), RoundedCornerShape(30.dp)), - contentAlignment = Alignment.Center - ) { - BasicText("🌐", style = TextStyle(fontSize = 44.sp)) - } - - Spacer(Modifier.height(28.dp)) - + OnboardingArtworkCard(backdrop, uiSensor, R.drawable.ic_onboarding_language, Color(0xFF4F7CFF), isDark) + Spacer(Modifier.height(22.dp)) BasicText( - stringResource(R.string.onboarding_language_title), - style = TextStyle(text, 26.sp, FontWeight.Bold, textAlign = TextAlign.Center) + 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_language_subtitle), - style = TextStyle(sub, 15.sp, textAlign = TextAlign.Center) + stringResource(R.string.onboarding_welcome_subtitle), + style = TextStyle(secondary, 15.sp, FontWeight.Medium, textAlign = TextAlign.Center) ) - - Spacer(Modifier.height(36.dp)) - + 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)) Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier + Modifier .fillMaxWidth() .liquidGlassPanel(backdrop, uiSensor) - .padding(16.dp) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - SUPPORTED_LANGUAGES.forEach { lang -> - val isSelected = selectedLocale == lang.code - val borderColor = if (isSelected) LiquidGlassColors.Blue else (if (isDark) Color.White.copy(0.12f) else Color.Black.copy(0.10f)) - val bgColor = if (isSelected) LiquidGlassColors.Blue.copy(0.18f) else (if (isDark) Color.White.copy(0.06f) else Color.White.copy(0.50f)) - + 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( Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) - .background(bgColor) - .border(1.5.dp, borderColor, RoundedCornerShape(16.dp)) - .clickable(role = Role.RadioButton) { onLanguageSelected(lang.code) } - .padding(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(14.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - BasicText(lang.flag, style = TextStyle(fontSize = 28.sp)) - Column(Modifier.weight(1f)) { - BasicText(lang.nativeName, style = TextStyle(text, 16.sp, FontWeight.SemiBold)) - if (lang.nativeName != lang.name) { - BasicText(lang.name, style = TextStyle(sub, 13.sp)) - } + 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)) } - if (isSelected) { + BasicText( + stringResource(language.labelRes), + style = TextStyle(text, 15.sp, if (selected) FontWeight.Bold else FontWeight.Medium), + modifier = Modifier.weight(1f) + ) + if (selected) { Box( Modifier - .size(24.dp) + .size(23.dp) .clip(CircleShape) - .background(LiquidGlassColors.Blue), + .background(Color(0xFF4F7CFF)), contentAlignment = Alignment.Center ) { Icon(Icons.Rounded.Check, null, Modifier.size(14.dp), Color.White) @@ -363,55 +384,44 @@ private fun LanguagePage( } } -// ─── Page 1: File Types ─────────────────────────────────────────────────────── - @Composable -private fun FilesPage(isDark: Boolean) { - val text = if (isDark) Color(0xFFF0F0F0) else Color(0xFF1A1A2E) - val sub = if (isDark) Color(0xFFAAAAAA) else Color(0xFF666699) - +private fun FormatsPage( + backdrop: LayerBackdrop, + uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, + isDark: Boolean, + text: Color, + secondary: Color +) { Column( - Modifier.fillMaxSize(), + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - Box( - Modifier - .size(100.dp) - .clip(RoundedCornerShape(30.dp)) - .background(Color(0xFF2E7D32).copy(0.15f)) - .border(1.dp, Color(0xFF2E7D32).copy(0.3f), RoundedCornerShape(30.dp)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.Folder, null, Modifier.size(52.dp), Color(0xFF4CAF50)) - } - - Spacer(Modifier.height(28.dp)) - + OnboardingArtworkCard(backdrop, uiSensor, R.drawable.ic_onboarding_formats, Color(0xFFFF5F6D), isDark) + Spacer(Modifier.height(22.dp)) BasicText( - stringResource(com.chethan616.clearpdf.R.string.onboarding_files_title), - style = TextStyle(text, 26.sp, FontWeight.Bold, textAlign = TextAlign.Center) + stringResource(R.string.onboarding_files_title), + style = TextStyle(text, 29.sp, FontWeight.Bold, textAlign = TextAlign.Center) ) Spacer(Modifier.height(8.dp)) BasicText( - stringResource(com.chethan616.clearpdf.R.string.onboarding_files_subtitle), - style = TextStyle(sub, 15.sp, textAlign = TextAlign.Center) + stringResource(R.string.onboarding_files_subtitle), + style = TextStyle(secondary, 15.sp, FontWeight.Medium, textAlign = TextAlign.Center) ) - - Spacer(Modifier.height(36.dp)) - - // File type grid - val rows = FILE_TYPES.chunked(3) - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - rows.forEach { row -> - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - row.forEach { item -> - FileTypePill(item, isDark, modifier = Modifier.weight(1f)) + 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)) } - // Fill empty cells if last row has < 3 items - repeat(3 - row.size) { Spacer(Modifier.weight(1f)) } } } } @@ -419,162 +429,202 @@ private fun FilesPage(isDark: Boolean) { } @Composable -private fun FileTypePill(item: FileTypeItem, isDark: Boolean, modifier: Modifier) { - val text = if (isDark) Color(0xFFF0F0F0) else Color(0xFF1A1A2E) +private fun FormatChip( + option: FormatOption, + isDark: Boolean, + text: Color, + modifier: Modifier +) { Column( modifier - .clip(RoundedCornerShape(20.dp)) - .background(if (isDark) Color.White.copy(0.06f) else Color.Black.copy(0.04f)) - .border(1.dp, if (isDark) Color.White.copy(0.12f) else Color.Black.copy(0.08f), RoundedCornerShape(20.dp)) - .padding(vertical = 14.dp, horizontal = 4.dp), + .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(8.dp) + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - androidx.compose.foundation.Image( - painter = androidx.compose.ui.res.painterResource(item.resId), - contentDescription = item.label, - modifier = Modifier.size(42.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) ) - BasicText(item.label, style = TextStyle(text, 12.sp, FontWeight.SemiBold, textAlign = TextAlign.Center)) } } -// ─── Page 2: Features ───────────────────────────────────────────────────────── - @Composable private fun FeaturesPage( backdrop: LayerBackdrop, uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, - isDark: Boolean + isDark: Boolean, + text: Color, + secondary: Color ) { - val text = if (isDark) Color(0xFFF0F0F0) else Color(0xFF1A1A2E) - val sub = if (isDark) Color(0xFFAAAAAA) else Color(0xFF666699) - Column( - Modifier.fillMaxSize(), + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - Box( - Modifier - .size(100.dp) - .clip(RoundedCornerShape(30.dp)) - .background(Color(0xFF7E57C2).copy(0.15f)) - .border(1.dp, Color(0xFF7E57C2).copy(0.3f), RoundedCornerShape(30.dp)), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.Draw, null, Modifier.size(52.dp), Color(0xFF7E57C2)) - } - - Spacer(Modifier.height(28.dp)) - + OnboardingArtworkCard(backdrop, uiSensor, R.drawable.ic_onboarding_tools, Color(0xFF7D5CFF), isDark) + Spacer(Modifier.height(22.dp)) BasicText( - stringResource(com.chethan616.clearpdf.R.string.onboarding_features_title), - style = TextStyle(text, 26.sp, FontWeight.Bold, textAlign = TextAlign.Center) + stringResource(R.string.onboarding_features_title), + style = TextStyle(text, 29.sp, FontWeight.Bold, textAlign = TextAlign.Center) ) Spacer(Modifier.height(8.dp)) BasicText( - stringResource(com.chethan616.clearpdf.R.string.onboarding_features_subtitle), - style = TextStyle(sub, 15.sp, textAlign = TextAlign.Center) + stringResource(R.string.onboarding_features_subtitle), + style = TextStyle(secondary, 15.sp, FontWeight.Medium, textAlign = TextAlign.Center) ) - - Spacer(Modifier.height(32.dp)) - + Spacer(Modifier.height(22.dp)) Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(9.dp) ) { - FEATURES.forEach { feature -> + FEATURE_OPTIONS.forEach { feature -> Row( Modifier .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(feature.color.copy(if (isDark) 0.1f else 0.07f)) - .border(1.dp, feature.color.copy(0.22f), RoundedCornerShape(16.dp)) - .padding(16.dp), + .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(14.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Box( Modifier - .size(44.dp) + .size(40.dp) .clip(RoundedCornerShape(12.dp)) - .background(feature.color.copy(0.2f)), + .background(feature.accent.copy(alpha = 0.20f)), contentAlignment = Alignment.Center ) { - Icon(feature.icon, null, Modifier.size(24.dp), feature.color) + Icon(feature.icon, null, Modifier.size(21.dp), feature.accent) } - Column { - BasicText(stringResource(feature.titleRes), style = TextStyle(text, 15.sp, FontWeight.SemiBold)) - BasicText(stringResource(feature.descRes), style = TextStyle(sub, 12.sp)) + 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)) } + Icon(Icons.Rounded.Check, null, Modifier.size(18.dp), feature.accent) } } } } } -// ─── Page 3: Ready CTA ──────────────────────────────────────────────────────── - @Composable -private fun ReadyPage(isDark: Boolean) { - val text = if (isDark) Color(0xFFF0F0F0) else Color(0xFF1A1A2E) - val sub = if (isDark) Color(0xFFAAAAAA) else Color(0xFF666699) - +private fun ReadyPage( + backdrop: LayerBackdrop, + uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, + isDark: Boolean, + text: Color, + secondary: Color +) { Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Animated check mark - var visible by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { delay(300); visible = true } - val iconScale by animateFloatAsState( - if (visible) 1f else 0f, - spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessMediumLow), - label = "iconScale" - ) - - Box( - Modifier - .size(120.dp) - .graphicsLayer { scaleX = iconScale; scaleY = iconScale } - .clip(CircleShape) - .background(Color(0xFF00C853).copy(0.15f)) - .border(2.dp, Color(0xFF00C853).copy(0.4f), CircleShape), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Rounded.Check, null, Modifier.size(60.dp), Color(0xFF00C853)) - } - - Spacer(Modifier.height(36.dp)) - + OnboardingArtworkCard(backdrop, uiSensor, R.drawable.ic_onboarding_ready, Color(0xFF33C88A), isDark) + Spacer(Modifier.height(26.dp)) BasicText( - stringResource(com.chethan616.clearpdf.R.string.onboarding_ready_title), - style = TextStyle(text, 28.sp, FontWeight.Bold, textAlign = TextAlign.Center) + stringResource(R.string.onboarding_ready_title), + style = TextStyle(text, 30.sp, FontWeight.Bold, textAlign = TextAlign.Center) ) - - Spacer(Modifier.height(12.dp)) - + Spacer(Modifier.height(10.dp)) BasicText( - stringResource(com.chethan616.clearpdf.R.string.onboarding_ready_subtitle), - style = TextStyle(sub, 16.sp, textAlign = TextAlign.Center) + stringResource(R.string.onboarding_ready_subtitle), + style = TextStyle(secondary, 15.sp, FontWeight.Medium, textAlign = TextAlign.Center) ) - - Spacer(Modifier.height(48.dp)) - - // Privacy badge + Spacer(Modifier.height(28.dp)) Row( Modifier - .clip(RoundedCornerShape(50)) - .background(LiquidGlassColors.Blue.copy(0.1f)) - .border(1.dp, LiquidGlassColors.Blue.copy(0.25f), RoundedCornerShape(50)) - .padding(horizontal = 16.dp, vertical = 8.dp), + .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(8.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - BasicText("🔒", style = TextStyle(fontSize = 14.sp)) - BasicText(stringResource(com.chethan616.clearpdf.R.string.onboarding_privacy_badge), - style = TextStyle(sub, 12.sp, FontWeight.Medium)) + 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)) + } + BasicText( + stringResource(R.string.onboarding_privacy_badge), + style = TextStyle(text, 12.sp, FontWeight.SemiBold) + ) } } } + +@Composable +private fun OnboardingArtworkCard( + backdrop: LayerBackdrop, + uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, + drawableRes: Int, + accent: Color, + isDark: Boolean +) { + 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 + ) { + 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) + ) + } +} 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 c320050..e6f3a67 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 @@ -1,5 +1,8 @@ package com.chethan616.clearpdf.ui.screen +import androidx.compose.ui.res.stringResource +import com.chethan616.clearpdf.R + import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image @@ -158,9 +161,9 @@ fun PageOrganizerScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), text) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) } - LiquidGlassTopBar("Organize Pages", backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) + LiquidGlassTopBar(stringResource(R.string.organize_screen_title), backdrop, uiSensor, Modifier.weight(1f), titleFontSize = 18.sp) } Column( @@ -184,14 +187,14 @@ fun PageOrganizerScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - BasicText("Reorder, rotate & delete pages", style = TextStyle(text, 18.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.organize_title), style = TextStyle(text, 18.sp, fontWeight = FontWeight.SemiBold)) BasicText( "Pick a PDF, drag pages to rearrange, then save a new copy. Original text & quality are preserved.", style = TextStyle(sub, 13.sp) ) LiquidButton(onClick = { picker.launch(arrayOf("application/pdf")) }, backdrop = backdrop, tint = accent) { Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) - BasicText("Pick a PDF", style = TextStyle(Color.White, 15.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, FontWeight.Medium)) } } state.errorMessage?.let { BasicText(it, style = TextStyle(Color(0xFFD32F2F), 13.sp)) } @@ -221,15 +224,15 @@ fun PageOrganizerScreen( LiquidButton(onClick = { viewModel.rotateSelected() }, backdrop = backdrop) { Icon(Icons.Rounded.RotateRight, null, Modifier.size(16.dp), text) } - LiquidButton(onClick = { viewModel.deleteSelected() }, backdrop = backdrop, tint = Color(0xFFEF5350)) { + LiquidButton(onClick = { viewModel.deleteSelected(context) }, backdrop = backdrop, tint = Color(0xFFEF5350)) { Icon(Icons.Rounded.Delete, null, Modifier.size(16.dp), Color.White) } LiquidButton(onClick = { viewModel.clearSelection() }, backdrop = backdrop) { - BasicText("Clear", style = TextStyle(text, 12.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.viewer_clear), style = TextStyle(text, 12.sp, FontWeight.Medium)) } } else { LiquidButton(onClick = { viewModel.selectAll() }, backdrop = backdrop) { - BasicText("Select all", style = TextStyle(text, 12.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.select_all), style = TextStyle(text, 12.sp, FontWeight.Medium)) } } } @@ -313,26 +316,26 @@ fun PageOrganizerScreen( page.thumbnail?.let { bmp -> Image( bitmap = bmp.asImageBitmap(), - contentDescription = "Page ${index + 1}", + contentDescription = stringResource(R.string.page_number, index + 1), contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().rotate(page.rotation.toFloat()) ) } } Column(Modifier.weight(1f)) { - BasicText("Page ${index + 1}", style = TextStyle(text, 14.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.create_page_label, index + 1), style = TextStyle(text, 14.sp, FontWeight.Medium)) BasicText( "Source #${page.originalIndex + 1}" + if (page.rotation != 0) " · ${page.rotation}°" else "", style = TextStyle(sub, 11.sp) ) } LiquidIconButton(onClick = { viewModel.rotatePage(index) }, backdrop = backdrop, surfaceColor = Color.White.copy(0.10f)) { - Icon(Icons.Rounded.RotateRight, "Rotate", Modifier.size(18.dp), text) + Icon(Icons.Rounded.RotateRight, stringResource(R.string.organize_rotate), Modifier.size(18.dp), text) } LiquidIconButton(onClick = { viewModel.deletePage(index) }, backdrop = backdrop, surfaceColor = Color(0xFFD32F2F).copy(0.18f)) { - Icon(Icons.Rounded.Delete, "Delete", Modifier.size(18.dp), Color(0xFFEF5350)) + Icon(Icons.Rounded.Delete, stringResource(R.string.delete), Modifier.size(18.dp), Color(0xFFEF5350)) } - Icon(Icons.Rounded.DragIndicator, "Drag", Modifier.size(22.dp), sub) + Icon(Icons.Rounded.DragIndicator, stringResource(R.string.organize_drag), Modifier.size(22.dp), sub) } } } @@ -390,7 +393,7 @@ fun PageOrganizerScreen( BasicText(msg, style = TextStyle(Color(0xFFB9F6CA), 12.sp), modifier = Modifier.weight(1f)) state.lastOutputUri?.let { uri -> LiquidButton(onClick = { onViewOutput(uri) }, backdrop = backdrop, tint = accent) { - BasicText("Open", style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) + BasicText(stringResource(R.string.open), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } } } 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 936a1f7..99f89fd 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 @@ -8,11 +8,15 @@ 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.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 @@ -69,6 +73,9 @@ 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 @@ -118,11 +125,14 @@ 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.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.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat @@ -143,7 +153,6 @@ import com.chethan616.clearpdf.ui.utils.rememberUISensor import com.chethan616.clearpdf.ui.viewmodel.OcrTextBlock import com.chethan616.clearpdf.ui.viewmodel.PdfViewerViewModel import com.kyant.backdrop.backdrops.LayerBackdrop -import androidx.compose.foundation.text.BasicTextField import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.input.TextFieldValue @@ -185,6 +194,7 @@ fun PdfViewerScreen( val uiSensor = rememberUISensor() val context = LocalContext.current val clipboard = LocalClipboardManager.current + val focusManager = LocalFocusManager.current val activity = context as? Activity val view = LocalView.current val scope = rememberCoroutineScope() @@ -224,6 +234,8 @@ fun PdfViewerScreen( 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 @@ -345,6 +357,14 @@ fun PdfViewerScreen( uri?.let { viewModel.openPdf(context, it) } } + LaunchedEffect(state.passwordUri) { + if (state.passwordUri != null) { + passwordText = "" + delay(120) + passwordFocusRequester.requestFocus() + } + } + if (state.document == null) { Column( Modifier.fillMaxSize().statusBarsPadding().padding(16.dp), @@ -352,9 +372,14 @@ fun PdfViewerScreen( ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), text) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) } - LiquidGlassTopBar("PDF Viewer", backdrop, uiSensor, Modifier.weight(1f)) + LiquidGlassTopBar( + stringResource(R.string.viewer_title), + backdrop, + uiSensor, + Modifier.weight(1f) + ) } Column( Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()), @@ -366,9 +391,12 @@ fun PdfViewerScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(56.dp), accent) - BasicText("Open a PDF", style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) BasicText( - "Select a PDF file from your device to view it", + 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( @@ -380,12 +408,82 @@ fun PdfViewerScreen( verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) - BasicText("Pick a PDF", style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + 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)) } } + + 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() + ) { + Column( + Modifier + .fillMaxWidth() + .liquidGlassPanel(backdrop, uiSensor) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + 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), + style = TextStyle(sub, 12.sp) + ) + 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)) + .background(if (isLight) Color.Black.copy(0.05f) else Color.White.copy(0.08f)) + .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)) + } + inner() + } + ) + 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)) + } + } + } + } } return } @@ -679,7 +777,7 @@ fun PdfViewerScreen( ) { Image( bitmap = bitmap.asImageBitmap(), - contentDescription = "Page ${page + 1}", + contentDescription = stringResource(R.string.page_number, page + 1), contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize() ) @@ -1160,7 +1258,7 @@ fun PdfViewerScreen( modifier = Modifier.fillMaxWidth() ) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.Black.copy(0.35f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), Color.White) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), Color.White) } LiquidGlassTopBar( title = stringResource(R.string.viewer_page_of, state.currentPage + 1, safePageCount), @@ -1196,21 +1294,51 @@ fun PdfViewerScreen( 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 && activeImageId == null, - enter = fadeIn(tween(200)) + androidx.compose.animation.slideInVertically { it / 2 }, - exit = fadeOut(tween(150)) + androidx.compose.animation.slideOutVertically { it / 2 } + 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) ) { - Row( - Modifier - .fillMaxWidth() - .liquidGlassPanel(backdrop, uiSensor) - .padding(horizontal = 12.dp, vertical = 10.dp) - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { + 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, @@ -1223,7 +1351,7 @@ fun PdfViewerScreen( tint = if (activeTool == PdfEditTool.SelectText) Color(0xFFAB47BC) else Color.Transparent ) { BasicText( - if (selectedTextCount > 0) "OCR ($selectedTextCount)" else stringResource(R.string.viewer_select_text), + if (selectedTextCount > 0) stringResource(R.string.viewer_ocr, selectedTextCount) else stringResource(R.string.viewer_select_text), style = TextStyle(Color.White, 12.sp, FontWeight.Medium) ) } @@ -1258,7 +1386,11 @@ fun PdfViewerScreen( onClick = { showFindBar = !showFindBar if (showFindBar) viewModel.triggerOcrForAllPages(context) - else { viewModel.clearSearch(); findQuery = "" } + else { + focusManager.clearFocus() + viewModel.clearSearch() + findQuery = "" + } }, backdrop = backdrop, tint = if (showFindBar) Color(0xFF0288D1) else Color.Transparent @@ -1304,7 +1436,10 @@ fun PdfViewerScreen( backdrop = backdrop ) { BasicText( - "Reset Zoom ${(zoomScale * 100 + 0.5f).toInt()}%", + stringResource( + R.string.viewer_reset_zoom, + (zoomScale * 100 + 0.5f).toInt() + ), style = TextStyle(Color.White, 12.sp, FontWeight.Medium) ) } @@ -1317,6 +1452,7 @@ fun PdfViewerScreen( tint = Color(0xFF1976D2) ) { BasicText(stringResource(R.string.viewer_save_edits), style = TextStyle(Color.White, 12.sp, FontWeight.Medium)) } } + } } } @@ -1324,7 +1460,18 @@ fun PdfViewerScreen( val showOcrTools = activeTool == PdfEditTool.SelectText || selectedTextCount > 0 val showImageTools = activeTool == PdfEditTool.Image && activeImageId != null - AnimatedVisibility(visible = (showDrawTools || showOcrTools || showImageTools) && !showFindBar) { + 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) @@ -1529,7 +1676,26 @@ fun PdfViewerScreen( .size(26.dp) .clip(CircleShape) .background(Color(cl)) - .clickable { currentColorLong = 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) @@ -1558,7 +1724,7 @@ fun PdfViewerScreen( ) { when { state.isExporting -> BasicText( - "Saving edited PDF…", + stringResource(R.string.viewer_saving), style = TextStyle(Color.White.copy(0.82f), 12.sp) ) state.exportError != null -> { @@ -1566,7 +1732,12 @@ fun PdfViewerScreen( LiquidButton( onClick = { viewModel.clearExportFeedback() }, backdrop = backdrop - ) { BasicText("Dismiss", style = TextStyle(Color.White, 11.sp, FontWeight.Medium)) } + ) { + 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)) @@ -1575,7 +1746,12 @@ fun PdfViewerScreen( onClick = { viewModel.openPdf(context, uri) }, backdrop = backdrop, tint = Color(0xFF1976D2) - ) { BasicText("Open", style = TextStyle(Color.White, 11.sp, FontWeight.Medium)) } + ) { + BasicText( + stringResource(R.string.open), + style = TextStyle(Color.White, 11.sp, FontWeight.Medium) + ) + } } } } @@ -1605,8 +1781,14 @@ fun PdfViewerScreen( AnimatedVisibility( visible = showFindBar, - enter = fadeIn(tween(200)) + androidx.compose.animation.slideInVertically { it }, - exit = fadeOut(tween(150)) + androidx.compose.animation.slideOutVertically { it }, + 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), modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() @@ -1693,7 +1875,7 @@ fun PdfViewerScreen( surfaceColor = Color.White.copy(0.1f), modifier = Modifier.size(32.dp) ) { - Icon(Icons.Rounded.KeyboardArrowUp, "Previous", Modifier.size(18.dp), Color.White) + Icon(Icons.Rounded.KeyboardArrowUp, stringResource(R.string.previous), Modifier.size(18.dp), Color.White) } LiquidIconButton( onClick = { viewModel.nextMatch(); lastInteractionAtMs = System.currentTimeMillis() }, @@ -1701,12 +1883,13 @@ fun PdfViewerScreen( surfaceColor = Color.White.copy(0.1f), modifier = Modifier.size(32.dp) ) { - Icon(Icons.Rounded.KeyboardArrowDown, "Next", Modifier.size(18.dp), Color.White) + Icon(Icons.Rounded.KeyboardArrowDown, stringResource(R.string.next), Modifier.size(18.dp), Color.White) } LiquidIconButton( onClick = { showFindBar = false + focusManager.clearFocus() findQuery = "" viewModel.clearSearch() }, @@ -1809,6 +1992,9 @@ 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, @@ -1856,7 +2042,7 @@ private fun PageScrubber( label = "thumbFraction" ) - val scrubberHeightDp = 280.dp + val scrubberHeightDp = 182.dp val trackWidthDp by androidx.compose.animation.core.animateDpAsState( targetValue = if (isDragging) 12.dp else 6.dp, @@ -1865,9 +2051,19 @@ private fun PageScrubber( ) Box( - modifier = modifier.padding(end = 8.dp), + 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) @@ -1920,6 +2116,24 @@ private fun PageScrubber( }, 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) @@ -1951,15 +2165,15 @@ private fun PageScrubber( scaleOut(targetScale = 0.9f) ) { val previewBitmap = pageBitmaps.getOrNull(dragPage) - val yOffsetDp = (scrubberHeightDp * animatedFraction - scrubberHeightDp / 2).coerceIn(-115.dp, 115.dp) + val yOffsetDp = (scrubberHeightDp * animatedFraction - scrubberHeightDp / 2).coerceIn(-78.dp, 78.dp) Column( modifier = Modifier .align(Alignment.CenterEnd) - .offset(x = (-46).dp, y = yOffsetDp) - .width(145.dp) + .offset(x = (-42).dp, y = yOffsetDp) + .width(124.dp) .liquidGlassPanel(backdrop, uiSensor) - .padding(10.dp), + .padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -1967,24 +2181,24 @@ private fun PageScrubber( Modifier .clip(RoundedCornerShape(50)) .background(accent.copy(0.22f)) - .padding(horizontal = 10.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 3.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { BasicText( - text = "PAGE", - style = TextStyle(color = accent, fontSize = 9.sp, fontWeight = FontWeight.Bold) + 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 = 12.sp, fontWeight = FontWeight.ExtraBold) + style = TextStyle(color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.ExtraBold) ) } Box( modifier = Modifier .fillMaxWidth() - .height(180.dp) + .height(140.dp) .clip(RoundedCornerShape(12.dp)) .background(Color.Black.copy(0.45f)) .border(1.dp, Color.White.copy(0.15f), RoundedCornerShape(12.dp)), @@ -1993,7 +2207,7 @@ private fun PageScrubber( if (previewBitmap != null && !previewBitmap.isRecycled) { Image( bitmap = previewBitmap.asImageBitmap(), - contentDescription = "Preview Page ${dragPage + 1}", + contentDescription = stringResource(R.string.preview_page, dragPage + 1), contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(4.dp) ) @@ -2058,7 +2272,7 @@ private fun LiquidPageJumpDialog( horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End) ) { LiquidButton(onClick = onDismiss, backdrop = backdrop) { - BasicText("Cancel", style = TextStyle(Color.White, 13.sp)) + BasicText(stringResource(R.string.cancel), style = TextStyle(Color.White, 13.sp)) } LiquidButton( onClick = { @@ -2068,13 +2282,28 @@ private fun LiquidPageJumpDialog( backdrop = backdrop, tint = Color(0xFF1976D2) ) { - BasicText("Go", style = TextStyle(Color.White, 13.sp, fontWeight = FontWeight.Bold)) + 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 +} + private sealed class PdfMarkup { data class StrokeMarkup( val points: List, 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 9073ff1..780296e 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 @@ -70,6 +70,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter 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.text.style.TextAlign @@ -77,6 +78,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.rememberAsyncImagePainter import com.chethan616.clearpdf.data.repository.SaveLocationManager +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 @@ -191,7 +193,7 @@ fun ScanDocumentScreen( ) } .addOnFailureListener { e -> - viewModel.setError("Scanner not available: ${e.message}") + viewModel.setError(context.getString(R.string.scanner_start_failed)) viewModel.cancelScanning() } } @@ -204,7 +206,7 @@ fun ScanDocumentScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { LiquidGlassTopBar( - title = "Scan Document", + title = stringResource(R.string.scan_title), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier @@ -283,11 +285,11 @@ fun ScanDocumentScreen( ) Spacer(Modifier.height(8.dp)) BasicText( - "Scan Document", + stringResource(R.string.scan_title), style = TextStyle(text, 20.sp, FontWeight.Bold, textAlign = TextAlign.Center) ) BasicText( - "Auto-detect edges, crop & enhance\nyour documents like a pro scanner", + stringResource(R.string.scan_description), style = TextStyle(sub, 14.sp, textAlign = TextAlign.Center) ) Spacer(Modifier.height(8.dp)) @@ -305,7 +307,7 @@ fun ScanDocumentScreen( modifier = Modifier.padding(vertical = 12.dp) ) { Icon(Icons.Rounded.CameraAlt, null, Modifier.size(20.dp), Color.White) - BasicText("Scan Document", style = TextStyle(Color.White, 16.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.scan_document), style = TextStyle(Color.White, 16.sp, FontWeight.SemiBold)) } } @@ -326,7 +328,7 @@ fun ScanDocumentScreen( modifier = Modifier.padding(vertical = 12.dp) ) { Icon(Icons.Rounded.PhotoLibrary, null, Modifier.size(20.dp), text) - BasicText("Import from Gallery", style = TextStyle(text, 16.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.import_gallery), style = TextStyle(text, 16.sp, FontWeight.SemiBold)) } } } @@ -339,14 +341,14 @@ fun ScanDocumentScreen( .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - BasicText("Features", style = TextStyle(text, 16.sp, FontWeight.Bold)) + BasicText(stringResource(R.string.scan_features), style = TextStyle(text, 16.sp, FontWeight.Bold)) val features = listOf( - Icons.Rounded.CropFree to "Auto edge detection & cropping", - Icons.Rounded.CameraAlt to "Perspective correction", - Icons.Rounded.DocumentScanner to "Multi-page document scanning", - Icons.Rounded.PhotoLibrary to "Import from gallery", - Icons.Rounded.PictureAsPdf to "Export as PDF" + Icons.Rounded.CropFree to stringResource(R.string.scan_feature_edges), + Icons.Rounded.CameraAlt to stringResource(R.string.scan_feature_perspective), + Icons.Rounded.DocumentScanner to stringResource(R.string.scan_feature_multi_page), + Icons.Rounded.PhotoLibrary to stringResource(R.string.scan_feature_gallery), + Icons.Rounded.PictureAsPdf to stringResource(R.string.scan_feature_export_pdf) ) features.forEach { (icon, feature) -> @@ -379,13 +381,13 @@ fun ScanDocumentScreen( verticalAlignment = Alignment.CenterVertically ) { BasicText( - "${state.scannedPages.size} page${if (state.scannedPages.size != 1) "s" else ""} scanned", + if (state.scannedPages.size == 1) stringResource(R.string.scan_page_scanned) else stringResource(R.string.scan_pages_scanned, state.scannedPages.size), style = TextStyle(text, 16.sp, FontWeight.SemiBold) ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { // Scan more pages Icon( - Icons.Rounded.DocumentScanner, "Scan more", + Icons.Rounded.DocumentScanner, stringResource(R.string.scan_more), Modifier .size(28.dp) .clip(CircleShape) @@ -395,7 +397,7 @@ fun ScanDocumentScreen( accent ) Icon( - Icons.Rounded.PhotoLibrary, "Add from gallery", + Icons.Rounded.PhotoLibrary, stringResource(R.string.add_from_gallery), Modifier .size(28.dp) .clip(CircleShape) @@ -408,7 +410,7 @@ fun ScanDocumentScreen( accent ) Icon( - Icons.Rounded.Delete, "Clear all", + Icons.Rounded.Delete, stringResource(R.string.clear_all), Modifier .size(28.dp) .clip(CircleShape) @@ -441,7 +443,7 @@ fun ScanDocumentScreen( ) { Image( painter = rememberAsyncImagePainter(page.uri), - contentDescription = "Page ${index + 1}", + contentDescription = stringResource(R.string.page_number, index + 1), contentScale = ContentScale.Crop, colorFilter = getComposeColorFilter(page.filter), modifier = Modifier.fillMaxSize() @@ -465,7 +467,7 @@ fun ScanDocumentScreen( // Delete button Icon( - Icons.Rounded.Close, "Remove page", + Icons.Rounded.Close, stringResource(R.string.remove_page), Modifier .align(Alignment.TopEnd) .padding(6.dp) @@ -488,7 +490,7 @@ fun ScanDocumentScreen( .padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - BasicText("Filter", style = TextStyle(text, 14.sp, FontWeight.SemiBold)) + BasicText(stringResource(R.string.filter), style = TextStyle(text, 14.sp, FontWeight.SemiBold)) Row( Modifier .fillMaxWidth() @@ -496,11 +498,11 @@ fun ScanDocumentScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val filters = listOf( - ScanFilter.ORIGINAL to "Original", - ScanFilter.AUTO to "Auto", - ScanFilter.GRAYSCALE to "Grayscale", - ScanFilter.BLACK_WHITE to "B&W", - ScanFilter.COLOR to "Vivid" + ScanFilter.ORIGINAL to stringResource(R.string.scan_original), + ScanFilter.AUTO to stringResource(R.string.scan_auto), + ScanFilter.GRAYSCALE to stringResource(R.string.scan_grayscale), + ScanFilter.BLACK_WHITE to stringResource(R.string.scan_black_white), + ScanFilter.COLOR to stringResource(R.string.scan_vivid) ) filters.forEach { (filter, label) -> val currentFilter = state.scannedPages.firstOrNull()?.filter ?: ScanFilter.ORIGINAL @@ -542,9 +544,9 @@ fun ScanDocumentScreen( if (GitHubStarPromptManager.recordPdfInteraction(context)) { StarPromptEventBus.requestPrompt() } - Toast.makeText(context, "PDF saved successfully!", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.pdf_saved_successfully), Toast.LENGTH_SHORT).show() } else { - viewModel.setError("Failed to save PDF") + viewModel.setError(context.getString(R.string.pdf_save_failed)) } } }, @@ -561,7 +563,7 @@ fun ScanDocumentScreen( ) { Icon(Icons.Rounded.PictureAsPdf, null, Modifier.size(22.dp), Color.White) BasicText( - if (state.isScanning) "Saving..." else "Save as PDF", + if (state.isScanning) stringResource(R.string.saving) else stringResource(R.string.save_as_pdf), style = TextStyle(Color.White, 16.sp, FontWeight.Bold) ) } 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 6fcc0a6..f2c62b3 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 @@ -39,6 +39,7 @@ 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.PhoneAndroid import androidx.compose.material.icons.rounded.Star @@ -62,6 +63,8 @@ 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.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 @@ -83,7 +86,9 @@ fun SettingsScreen( isDarkMode: Boolean = false, onDarkModeChanged: (Boolean) -> Unit = {}, themeMode: Int = 0, - onThemeModeChanged: (Int) -> Unit = {} + onThemeModeChanged: (Int) -> Unit = {}, + selectedLocale: String = "en", + onLocaleChanged: (String) -> Unit = {} ) { val isLight = !isDarkMode val text = if (isLight) Color(0xFF222222) else Color(0xFFF0F0F0) @@ -224,7 +229,7 @@ fun SettingsScreen( translationY = topBarOffsetY * density } ) { - LiquidGlassTopBar(title = "Settings", backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.fillMaxWidth()) + LiquidGlassTopBar(title = stringResource(R.string.settings_title), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.fillMaxWidth()) } // ── Theme Mode Selector ── @@ -244,7 +249,7 @@ fun SettingsScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon(Icons.Rounded.Tune, null, Modifier.size(22.dp), label) - BasicText("Appearance", style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.settings_appearance), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } // Liquid Glass Theme Mode Selector @@ -254,9 +259,9 @@ fun SettingsScreen( ) { data class ThemeOption(val idx: Int, val label: String, val icon: ImageVector, val activeColor: Color) val options = listOf( - ThemeOption(0, "Auto", Icons.Rounded.PhoneAndroid, Color(0xFF0088FF)), - ThemeOption(1, "Light", Icons.Rounded.LightMode, Color(0xFFFFA726)), - ThemeOption(2, "Dark", Icons.Rounded.DarkMode, Color(0xFF7C4DFF)) + 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)) ) options.forEach { option -> val isSelected = themeMode == option.idx @@ -292,9 +297,9 @@ fun SettingsScreen( BasicText( when (themeMode) { - 1 -> "Always use light theme" - 2 -> "Always use dark theme" - else -> "Follows your device's system settings" + 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(sub, 12.sp) ) @@ -317,7 +322,7 @@ fun SettingsScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon(Icons.Rounded.FolderOpen, null, Modifier.size(22.dp), Color(0xFF0088FF)) - BasicText("Save Location", style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.settings_save_location), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } // Liquid Glass Save Location Mode Selector @@ -349,7 +354,7 @@ fun SettingsScreen( ) { Icon(Icons.Rounded.Download, null, Modifier.size(17.dp), defContentColor) BasicText( - "Downloads", + stringResource(R.string.settings_save_downloads), style = TextStyle( defContentColor, 13.sp, @@ -375,7 +380,7 @@ fun SettingsScreen( ) { Icon(Icons.Rounded.FolderSpecial, null, Modifier.size(17.dp), customContentColor) BasicText( - "Custom Folder", + stringResource(R.string.settings_save_custom), style = TextStyle( customContentColor, 13.sp, @@ -414,12 +419,12 @@ fun SettingsScreen( } Column(Modifier.weight(1f)) { BasicText( - if (saveUri != null) "Custom Directory" else "Default Directory", + if (saveUri != null) stringResource(R.string.settings_custom_directory) else stringResource(R.string.settings_default_directory), style = TextStyle(label, 13.sp, fontWeight = FontWeight.SemiBold) ) val path = if (saveUri != null) { saveUri!!.lastPathSegment?.replace("primary:", "") ?: saveUri.toString() - } else "Downloads / ClearPDF" + } else stringResource(R.string.settings_default_path) BasicText(path, style = TextStyle(sub, 12.sp)) } } @@ -443,13 +448,13 @@ fun SettingsScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon(Icons.Rounded.AutoFixHigh, null, Modifier.size(22.dp), label) - BasicText("File Handling", style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.settings_file_handling), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } SettingsToggleRow( icon = Icons.Rounded.AutoFixHigh, - title = "Auto-Compress", - desc = "Compress PDFs automatically on import", + title = stringResource(R.string.settings_auto_compress), + desc = stringResource(R.string.settings_auto_compress_desc), checked = autoCompress, onCheckedChange = { autoCompress = it; AppSettingsManager.setAutoCompress(context, it) }, backdrop = backdrop, @@ -465,8 +470,8 @@ fun SettingsScreen( SettingsToggleRow( icon = Icons.Rounded.FileCopy, - title = "Keep Original", - desc = "Preserve original file after editing", + title = stringResource(R.string.settings_keep_original), + desc = stringResource(R.string.settings_keep_original_desc), checked = keepOriginal, onCheckedChange = { keepOriginal = it; AppSettingsManager.setKeepOriginal(context, it) }, backdrop = backdrop, @@ -481,8 +486,8 @@ fun SettingsScreen( SettingsToggleRow( icon = Icons.Rounded.Notifications, - title = "Notifications", - desc = "Show notification when tasks complete", + title = stringResource(R.string.settings_notifications), + desc = stringResource(R.string.settings_notifications_desc), checked = notifications, onCheckedChange = { notifications = it; AppSettingsManager.setNotifications(context, it) }, backdrop = backdrop, @@ -513,7 +518,7 @@ fun SettingsScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon(Icons.Rounded.HighQuality, null, Modifier.size(22.dp), Color(0xFF1976D2)) - BasicText("Compression Quality", style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.settings_compression_quality), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } Box( Modifier @@ -543,13 +548,12 @@ fun SettingsScreen( ) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - BasicText("Smaller file size", style = TextStyle(sub.copy(0.7f), 11.sp)) - BasicText("Higher image clarity", style = TextStyle(sub.copy(0.7f), 11.sp)) + BasicText(stringResource(R.string.settings_smaller_size), style = TextStyle(sub.copy(0.7f), 11.sp)) + BasicText(stringResource(R.string.settings_higher_quality), style = TextStyle(sub.copy(0.7f), 11.sp)) } } // ── Language Selection ── - val currentLang = remember { mutableStateOf(com.chethan616.clearpdf.data.repository.OnboardingManager.getSelectedLocale(context)) } Column( Modifier .fillMaxWidth() @@ -565,8 +569,8 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - BasicText("🌐", style = TextStyle(fontSize = 20.sp)) - BasicText("Language / Idioma", style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) + 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)) } Row( @@ -574,18 +578,15 @@ fun SettingsScreen( horizontalArrangement = Arrangement.spacedBy(10.dp) ) { val languages = listOf( - Pair("en", "🇬🇧 English"), - Pair("pt-BR", "🇧🇷 Português") + Pair("en", stringResource(R.string.language_english)), + Pair("pt-BR", stringResource(R.string.language_portuguese)) ) languages.forEach { (code, labelText) -> - val isSelected = currentLang.value == code + val isSelected = selectedLocale == code val activeColor = Color(0xFF0088FF) val contentColor = if (isSelected) Color.White else (if (isLight) Color(0xFF2C2C2E) else Color(0xFFE0E0E0)) LiquidButton( - onClick = { - currentLang.value = code - com.chethan616.clearpdf.ui.utils.LocaleHelper.applyLocale(context, code, recreate = true) - }, + onClick = { onLocaleChanged(code) }, 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)), @@ -626,9 +627,9 @@ fun SettingsScreen( Icon(Icons.Rounded.Info, null, Modifier.size(28.dp), Color(0xFF0088FF)) } BasicText("ClearPDF", style = TextStyle(text, 20.sp, fontWeight = FontWeight.Bold)) - BasicText("Version 1.0.0", style = TextStyle(sub, 13.sp)) + BasicText(stringResource(R.string.settings_version), style = TextStyle(sub, 13.sp)) BasicText( - "Made by Chethan616 with ❤", + stringResource(R.string.settings_made_by), style = TextStyle(sub, 13.sp, textAlign = TextAlign.Center) ) @@ -646,12 +647,12 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Rounded.Star, null, Modifier.size(18.dp), Color.White) - BasicText("Star on GitHub", style = TextStyle(Color.White, 14.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.settings_star_github), style = TextStyle(Color.White, 14.sp, fontWeight = FontWeight.SemiBold)) } } BasicText( - "ClearPDF is open source", + stringResource(R.string.settings_open_source), style = TextStyle(sub.copy(0.7f), 11.sp, textAlign = TextAlign.Center) ) } @@ -673,7 +674,7 @@ fun SettingsScreen( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon(Icons.Rounded.Code, null, Modifier.size(22.dp), label) - BasicText("Open Source Licenses", style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.settings_licenses), style = TextStyle(text, 17.sp, fontWeight = FontWeight.SemiBold)) } LicenseItem( @@ -691,7 +692,7 @@ fun SettingsScreen( ) BasicText( - "Licensed under the Apache License, Version 2.0.\nYou may obtain a copy at apache.org/licenses/LICENSE-2.0", + stringResource(R.string.settings_license_notice), style = TextStyle(sub.copy(0.7f), 11.sp, lineHeight = 16.sp) ) } @@ -775,7 +776,7 @@ private fun LicenseItem( horizontalArrangement = Arrangement.spacedBy(6.dp) ) { BasicText(name, style = TextStyle(labelColor, 14.sp, fontWeight = FontWeight.Medium)) - BasicText("by $author", style = TextStyle(subColor, 12.sp)) + 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)) 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 aa01b01..6b9c51a 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,6 +5,10 @@ import android.graphics.Canvas import android.graphics.Paint import androidx.compose.ui.res.stringResource import com.chethan616.clearpdf.R +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -18,6 +22,7 @@ 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.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -26,6 +31,9 @@ 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.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.Check import androidx.compose.material.icons.rounded.Close @@ -33,10 +41,12 @@ import androidx.compose.material.icons.rounded.Undo import androidx.compose.material.icons.rounded.Gesture import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,12 +56,16 @@ 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.drawscope.Stroke import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -62,6 +76,7 @@ import com.chethan616.clearpdf.ui.components.LiquidIconButton import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.utils.rememberUISensor import com.kyant.backdrop.backdrops.LayerBackdrop +import kotlinx.coroutines.delay /** * Full-screen signature capture dialog. Presents a dark canvas for the user to draw @@ -98,28 +113,74 @@ fun SignaturePadDialog( 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) } + var signatureName by rememberSaveable { mutableStateOf("") } + var pendingSignature by remember { mutableStateOf(null) } + val nameFocusRequester = remember { FocusRequester() } - val savedSignatures = remember { - mutableStateListOf().apply { + LaunchedEffect(Unit) { + contentVisible = true + } + + LaunchedEffect(showNamePrompt) { + if (showNamePrompt) { + delay(120) + runCatching { nameFocusRequester.requestFocus() } + } + } + + data class SavedSignature(val name: String, val bitmap: Bitmap) + val savedSignatures = remember(context) { + mutableStateListOf().apply { try { - addAll(com.chethan616.clearpdf.data.repository.SignatureManager.listSignatures(context).mapNotNull { com.chethan616.clearpdf.data.repository.SignatureManager.loadSignature(it) }) + com.chethan616.clearpdf.data.repository.SignatureManager + .listSignatures(context) + .mapNotNull { file -> + com.chethan616.clearpdf.data.repository.SignatureManager + .loadSignature(file) + ?.let { SavedSignature( + com.chethan616.clearpdf.data.repository.SignatureManager.displayName(file), + it + ) } + } + .forEach(::add) } catch (_: Throwable) {} } } + val confirmSignatureName = { + val bitmap = pendingSignature + val trimmedName = signatureName.trim() + if (bitmap != null && trimmedName.isNotEmpty()) { + runCatching { + com.chethan616.clearpdf.data.repository.SignatureManager + .saveSignature(context, bitmap, trimmedName) + } + showNamePrompt = false + pendingSignature = null + onSignatureCaptured(bitmap) + } + } + Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnBackPress = true) ) { - Column( - Modifier - .fillMaxSize() - .background(Color(0xFF0A0A0A)) - .statusBarsPadding() - .navigationBarsPadding() - .padding(16.dp), - verticalArrangement = Arrangement.SpaceBetween + AnimatedVisibility( + visible = contentVisible, + modifier = Modifier.fillMaxSize(), + enter = fadeIn(tween(220)) + scaleIn(initialScale = 0.98f) ) { + Column( + Modifier + .fillMaxSize() + .background(Color(0xFF0A0A0A)) + .statusBarsPadding() + .navigationBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { // Header Row( Modifier.fillMaxWidth(), @@ -132,7 +193,7 @@ fun SignaturePadDialog( surfaceColor = Color.White.copy(0.08f), modifier = Modifier.size(44.dp) ) { - Icon(Icons.Rounded.Close, "Close", Modifier.size(20.dp), Color.White) + Icon(Icons.Rounded.Close, stringResource(R.string.close), Modifier.size(20.dp), Color.White) } BasicText( @@ -146,7 +207,7 @@ fun SignaturePadDialog( surfaceColor = Color.White.copy(0.08f), modifier = Modifier.size(44.dp) ) { - Icon(Icons.Rounded.Undo, "Undo", Modifier.size(20.dp), Color.White) + Icon(Icons.Rounded.Undo, stringResource(R.string.undo), Modifier.size(20.dp), Color.White) } } @@ -273,7 +334,7 @@ fun SignaturePadDialog( .background(Color.White.copy(0.15f)) ) BasicText( - "Sign here", + stringResource(R.string.sig_hint), style = TextStyle(Color.White.copy(0.2f), 14.sp), modifier = Modifier .align(Alignment.BottomCenter) @@ -325,7 +386,7 @@ fun SignaturePadDialog( if (savedSignatures.isNotEmpty()) { Spacer(Modifier.height(10.dp)) BasicText( - "Saved Signatures", + stringResource(R.string.sig_saved_title), style = TextStyle(Color.White.copy(0.7f), 12.sp, FontWeight.SemiBold) ) Row( @@ -342,10 +403,10 @@ fun SignaturePadDialog( .background(Color.White.copy(0.10f)) .border(1.dp, Color.White.copy(0.2f), RoundedCornerShape(10.dp)) .clickable { - val safeBmp = if (savedBmp.config == Bitmap.Config.HARDWARE || !savedBmp.isMutable) { - savedBmp.copy(Bitmap.Config.ARGB_8888, true) + val safeBmp = if (savedBmp.bitmap.config == Bitmap.Config.HARDWARE || !savedBmp.bitmap.isMutable) { + savedBmp.bitmap.copy(Bitmap.Config.ARGB_8888, true) } else { - savedBmp + savedBmp.bitmap } onSignatureCaptured(safeBmp) }, @@ -356,7 +417,7 @@ fun SignaturePadDialog( verticalAlignment = Alignment.CenterVertically ) { Icon(Icons.Rounded.Gesture, null, Modifier.size(12.dp), Color.White) - BasicText(stringResource(R.string.sig_saved), style = TextStyle(Color.White, 11.sp, FontWeight.Medium)) + BasicText(savedBmp.name, style = TextStyle(Color.White, 11.sp, FontWeight.Medium)) } } } @@ -366,7 +427,20 @@ fun SignaturePadDialog( Spacer(Modifier.height(14.dp)) // Bottom actions - Row( + if (showNamePrompt) { + SignatureNameBar( + backdrop = backdrop, + uiSensor = uiSensor, + name = signatureName, + focusRequester = nameFocusRequester, + onNameChange = { signatureName = it }, + onCancel = { + showNamePrompt = false + pendingSignature = null + }, + onSave = confirmSignatureName + ) + } else Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -411,8 +485,9 @@ fun SignaturePadDialog( stroke.drop(1).forEach { path.lineTo(it.x, it.y) } canvas.drawPath(path, paint) } - runCatching { com.chethan616.clearpdf.data.repository.SignatureManager.saveSignature(context, bmp) } - onSignatureCaptured(bmp) + pendingSignature = bmp + signatureName = "" + showNamePrompt = true } catch (e: Throwable) { e.printStackTrace() } @@ -437,3 +512,82 @@ fun SignaturePadDialog( } } } +} + +@Composable +private fun SignatureNameBar( + backdrop: LayerBackdrop, + uiSensor: com.chethan616.clearpdf.ui.utils.UISensor, + name: String, + focusRequester: FocusRequester, + onNameChange: (String) -> Unit, + onCancel: () -> Unit, + onSave: () -> Unit +) { + val canSave = name.trim().isNotEmpty() + + Row( + Modifier + .fillMaxWidth() + .imePadding() + .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(8.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) + ) + } + 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) + ) + } + } +} 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 e4a3a86..77652bb 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 @@ -1,5 +1,8 @@ package com.chethan616.clearpdf.ui.screen +import androidx.compose.ui.res.stringResource +import com.chethan616.clearpdf.R + import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -139,9 +142,9 @@ fun SplitPdfScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { LiquidButton(onClick = onBack, backdrop = backdrop, surfaceColor = Color.White.copy(0.08f)) { - Icon(Icons.Rounded.ArrowBackIosNew, "Back", Modifier.size(18.dp), text) + Icon(Icons.Rounded.ArrowBackIosNew, stringResource(R.string.back), Modifier.size(18.dp), text) } - LiquidGlassTopBar(title = "Split PDF", backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) + LiquidGlassTopBar(title = stringResource(R.string.tool_split), backdrop = backdrop, uiSensor = uiSensor, modifier = Modifier.weight(1f)) } Column( @@ -163,8 +166,8 @@ fun SplitPdfScreen( verticalArrangement = Arrangement.spacedBy(14.dp) ) { Icon(Icons.AutoMirrored.Rounded.CallSplit, null, Modifier.size(52.dp), accent) - BasicText("Extract or split pages", style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) - BasicText("Pick one mode, then run one clear primary action.", style = TextStyle(sub, 13.sp, textAlign = TextAlign.Center)) + BasicText(stringResource(R.string.tool_split), style = TextStyle(text, 20.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.tool_split_description), style = TextStyle(sub, 13.sp, textAlign = TextAlign.Center)) LiquidButton( onClick = { filePicker.launch(arrayOf("application/pdf")) }, @@ -173,7 +176,7 @@ fun SplitPdfScreen( ) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Rounded.UploadFile, null, Modifier.size(18.dp), Color.White) - BasicText("Select PDF", style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) + BasicText(stringResource(R.string.viewer_pick_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.Medium)) } } } @@ -187,7 +190,7 @@ fun SplitPdfScreen( verticalArrangement = Arrangement.spacedBy(6.dp) ) { BasicText(state.sourceFileName, style = TextStyle(text, 15.sp, fontWeight = FontWeight.SemiBold)) - BasicText("${state.pageCount} pages", style = TextStyle(sub, 13.sp)) + BasicText(stringResource(R.string.create_pages, state.pageCount), style = TextStyle(sub, 13.sp)) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { @@ -232,10 +235,10 @@ fun SplitPdfScreen( ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - QuickSelectChip(label = "All", onClick = { viewModel.onSelectAllPages() }, text = text, accent = accent) - QuickSelectChip(label = "Odd", onClick = { viewModel.onSelectOddPages() }, text = text, accent = accent) - QuickSelectChip(label = "Even", onClick = { viewModel.onSelectEvenPages() }, text = text, accent = accent) - QuickSelectChip(label = "Clear", onClick = { viewModel.onClearSelection() }, text = text, accent = accent) + QuickSelectChip(label = stringResource(R.string.split_quick_all), onClick = { viewModel.onSelectAllPages() }, text = text, accent = accent) + QuickSelectChip(label = stringResource(R.string.split_quick_odd), onClick = { viewModel.onSelectOddPages() }, text = text, accent = accent) + QuickSelectChip(label = stringResource(R.string.split_quick_even), onClick = { viewModel.onSelectEvenPages() }, text = text, accent = accent) + QuickSelectChip(label = stringResource(R.string.viewer_clear), onClick = { viewModel.onClearSelection() }, text = text, accent = accent) } FlowRow( @@ -259,7 +262,7 @@ fun SplitPdfScreen( if (thumb != null) { Image( bitmap = thumb.asImageBitmap(), - contentDescription = "Page ${i + 1}", + contentDescription = stringResource(R.string.page_number, i + 1), contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) @@ -357,7 +360,7 @@ fun SplitPdfScreen( tint = Color(0xFF1976D2), modifier = Modifier.fillMaxWidth() ) { - BasicText("View PDF", style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.SemiBold)) + BasicText(stringResource(R.string.viewer_open_pdf), style = TextStyle(Color.White, 15.sp, fontWeight = FontWeight.SemiBold)) } } 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 418edcb..7c05bbd 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 @@ -22,6 +22,8 @@ import androidx.compose.material.icons.automirrored.rounded.NoteAdd import androidx.compose.material.icons.rounded.Compress import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.Reorder import androidx.compose.material.icons.rounded.TextSnippet import androidx.compose.material3.Icon @@ -69,7 +71,9 @@ fun ToolsScreen( onNavigateToCreatePdf: () -> Unit, onNavigateToOrganizePdf: () -> Unit = {}, onNavigateToExtractText: () -> Unit = {}, - onNavigateToImagesToPdf: () -> Unit = {} + onNavigateToImagesToPdf: () -> Unit = {}, + onNavigateToDecryptPdf: () -> Unit = {}, + onNavigateToEncryptPdf: () -> Unit = {} ) { val isDarkMode = LocalIsDarkMode.current val uiSensor = rememberUISensor() @@ -91,6 +95,10 @@ fun ToolsScreen( 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, @@ -101,6 +109,8 @@ fun ToolsScreen( toolImagesTitle, toolImagesSub, toolExtractTitle, toolExtractSub, toolCreateTitle, toolCreateSub, + toolDecryptTitle, toolDecryptSub, + toolEncryptTitle, toolEncryptSub, onNavigateToOpenPdf, onNavigateToMergePdf, onNavigateToSplitPdf, @@ -108,7 +118,9 @@ fun ToolsScreen( onNavigateToOrganizePdf, onNavigateToImagesToPdf, onNavigateToExtractText, - onNavigateToCreatePdf + onNavigateToCreatePdf, + onNavigateToDecryptPdf, + onNavigateToEncryptPdf ) { listOf( ToolSpec("open", toolOpenPdfTitle, toolOpenPdfSub, LiquidGlassColors.Blue, Icons.Rounded.FileOpen, onNavigateToOpenPdf), @@ -118,7 +130,9 @@ fun ToolsScreen( 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("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) ) } 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 6297df4..f53d9c3 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 @@ -2,6 +2,7 @@ package com.chethan616.clearpdf.ui.utils import android.content.Context import android.content.res.Configuration +import android.os.Build import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import com.chethan616.clearpdf.data.repository.OnboardingManager @@ -9,8 +10,15 @@ import java.util.Locale object LocaleHelper { + private fun normalizeLanguageTag(languageTag: String): String { + val locale = Locale.forLanguageTag(languageTag.replace('_', '-')) + return if (locale.language.equals("pt", ignoreCase = true)) "pt-BR" else "en" + } + + fun normalizeForUi(languageTag: String): String = normalizeLanguageTag(languageTag) + fun getLocalizedContext(context: Context, languageTag: String): Context { - val locale = parseLocale(languageTag) + val locale = parseLocale(normalizeLanguageTag(languageTag)) Locale.setDefault(locale) val config = Configuration(context.resources.configuration) config.setLocale(locale) @@ -18,39 +26,37 @@ object LocaleHelper { } private fun parseLocale(languageTag: String): Locale { - return if (languageTag.contains("-")) { - val parts = languageTag.split("-") - Locale(parts[0], parts[1]) - } else if (languageTag.contains("_")) { - val parts = languageTag.split("_") - Locale(parts[0], parts[1]) - } else { - Locale(languageTag) - } + return Locale.forLanguageTag(languageTag.replace('_', '-')) } - fun applyLocale(context: Context, languageTag: String, recreate: Boolean = false) { - OnboardingManager.setSelectedLocale(context, languageTag) + fun applyLocale( + context: Context, + languageTag: String, + recreate: Boolean = false, + updateAppCompat: Boolean = true + ) { + val normalizedTag = normalizeLanguageTag(languageTag) + OnboardingManager.setSelectedLocale(context, normalizedTag) - val locale = parseLocale(languageTag) + val locale = parseLocale(normalizedTag) Locale.setDefault(locale) - val resources = context.resources - val config = Configuration(resources.configuration) - config.setLocale(locale) - @Suppress("DEPRECATION") - resources.updateConfiguration(config, resources.displayMetrics) - - val appRes = context.applicationContext.resources - val appConfig = Configuration(appRes.configuration) - appConfig.setLocale(locale) - @Suppress("DEPRECATION") - appRes.updateConfiguration(appConfig, appRes.displayMetrics) + // AppCompat owns the activity configuration on modern Android. The + // legacy resource update is kept only for API 23, where per-app + // language APIs are not available. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + val config = Configuration(context.resources.configuration) + config.setLocale(locale) + @Suppress("DEPRECATION") + context.resources.updateConfiguration(config, context.resources.displayMetrics) + } - // Android 13+ / AppCompat per-app language - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags(languageTag) - ) + if (updateAppCompat) { + val requestedLocales = LocaleListCompat.forLanguageTags(normalizedTag) + if (AppCompatDelegate.getApplicationLocales() != requestedLocales) { + AppCompatDelegate.setApplicationLocales(requestedLocales) + } + } if (recreate) { (context as? android.app.Activity)?.recreate() @@ -58,7 +64,7 @@ object LocaleHelper { } fun getLanguageDisplayName(languageTag: String): String { - return when (languageTag) { + return when (normalizeLanguageTag(languageTag)) { "pt-BR", "pt" -> "Português (Brasil)" "en" -> "English" else -> "English" 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 3056d52..8d770d1 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 @@ -14,6 +14,7 @@ import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager 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.CompressPdfUseCase import com.chethan616.clearpdf.ui.utils.StarPromptEventBus import com.kyant.pdfcore.model.CompressionQuality @@ -72,7 +73,7 @@ class CompressPdfViewModel(private val compressPdfUseCase: CompressPdfUseCase) : estimatedSizeBytes = estimate ) } catch (e: Exception) { - _uiState.value = _uiState.value.copy(errorMessage = e.message) + _uiState.value = _uiState.value.copy(errorMessage = context.getString(R.string.open_pdf_failed)) } } } @@ -162,7 +163,7 @@ class CompressPdfViewModel(private val compressPdfUseCase: CompressPdfUseCase) : compressedSizeBytes = result.sizeBytes, lastOutputUri = outUri, saveLocationLabel = saveLabel, - resultMessage = "Compressed: ${origKb}KB → ${compKb}KB (${reduction}% smaller)\nSaved to $saveLabel" + resultMessage = context.getString(R.string.compression_success, origKb, compKb, reduction, saveLabel) ) if (GitHubStarPromptManager.recordPdfInteraction(context)) { @@ -171,7 +172,7 @@ class CompressPdfViewModel(private val compressPdfUseCase: CompressPdfUseCase) : } catch (e: Exception) { _uiState.value = _uiState.value.copy( isCompressing = false, - errorMessage = e.message ?: "Compression failed" + errorMessage = context.getString(R.string.compression_failed) ) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CreatePdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CreatePdfViewModel.kt index b13e459..b565960 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CreatePdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/CreatePdfViewModel.kt @@ -16,6 +16,7 @@ import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager 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.CreatePdfUseCase import com.chethan616.clearpdf.ui.utils.StarPromptEventBus import kotlinx.coroutines.Dispatchers @@ -101,19 +102,19 @@ class CreatePdfViewModel(private val createPdfUseCase: CreatePdfUseCase) : ViewM when (state.selectedMode) { CreateMode.FROM_IMAGES -> { if (state.selectedImageUris.isEmpty()) { - _uiState.update { it.copy(errorMessage = "Please select at least one image") } + _uiState.update { it.copy(errorMessage = context.getString(R.string.create_min_image)) } return } } CreateMode.BLANK -> { if (state.blankPageCount < 1) { - _uiState.update { it.copy(errorMessage = "Blank PDF needs at least 1 page") } + _uiState.update { it.copy(errorMessage = context.getString(R.string.create_min_blank)) } return } } CreateMode.ADVANCED_TEXT -> { if (state.textContent.isBlank()) { - _uiState.update { it.copy(errorMessage = "Please enter some text") } + _uiState.update { it.copy(errorMessage = context.getString(R.string.create_text_required)) } return } } @@ -167,7 +168,7 @@ class CreatePdfViewModel(private val createPdfUseCase: CreatePdfUseCase) : ViewM isCreating = false, lastOutputUri = outputUri, saveLocationLabel = saveLabel, - resultMessage = "Created $fileName (${doc.pageCount} pages)\nSaved to $saveLabel" + resultMessage = context.getString(R.string.create_success, fileName, doc.pageCount, saveLabel) ) } @@ -176,7 +177,7 @@ class CreatePdfViewModel(private val createPdfUseCase: CreatePdfUseCase) : ViewM } } catch (e: Exception) { _uiState.update { - it.copy(isCreating = false, errorMessage = e.message ?: "Failed to create PDF") + it.copy(isCreating = false, errorMessage = context.getString(R.string.create_failed)) } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractTextViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractTextViewModel.kt index ec0f321..450c5d8 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractTextViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ExtractTextViewModel.kt @@ -6,6 +6,7 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kyant.pdfcore.converter.PdfConverter +import com.chethan616.clearpdf.R import com.kyant.pdfcore.model.PdfDocument import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -42,11 +43,11 @@ class ExtractTextViewModel(private val converter: PdfConverter) : ViewModel() { text = extracted, hasResult = true, errorMessage = if (extracted.isEmpty()) - "No selectable text found. This PDF may be a scan/image — try the scanner's OCR instead." + context.getString(R.string.extract_no_selectable_text) else null ) } catch (e: Exception) { - _uiState.value = ExtractTextUiState(isExtracting = false, errorMessage = e.message ?: "Extraction failed") + _uiState.value = ExtractTextUiState(isExtracting = false, errorMessage = context.getString(R.string.text_extraction_failed)) } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImagesToPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImagesToPdfViewModel.kt index a8766d5..4a2c460 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImagesToPdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/ImagesToPdfViewModel.kt @@ -13,6 +13,7 @@ import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager 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.ui.utils.StarPromptEventBus import com.kyant.pdfcore.converter.PdfConverter import kotlinx.coroutines.Dispatchers @@ -62,7 +63,7 @@ class ImagesToPdfViewModel(private val converter: PdfConverter) : ViewModel() { fun save(context: Context, fileName: String, overrideUri: Uri? = null) { val images = _uiState.value.imageUris if (images.isEmpty()) { - _uiState.value = _uiState.value.copy(errorMessage = "Add at least one image") + _uiState.value = _uiState.value.copy(errorMessage = context.getString(R.string.images_min_one)) return } _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, resultMessage = null, lastOutputUri = null) @@ -84,11 +85,11 @@ class ImagesToPdfViewModel(private val converter: PdfConverter) : ViewModel() { isSaving = false, lastOutputUri = outUri, saveLocationLabel = saveLabel, - resultMessage = "Created ${images.size}-page PDF\nSaved to $saveLabel" + resultMessage = context.getString(R.string.images_success, images.size, saveLabel) ) if (GitHubStarPromptManager.recordPdfInteraction(context)) StarPromptEventBus.requestPrompt() } catch (e: Exception) { - _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = e.message ?: "Conversion failed") + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = context.getString(R.string.conversion_failed)) } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/MergePdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/MergePdfViewModel.kt index f89553e..a3ba911 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/MergePdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/MergePdfViewModel.kt @@ -14,6 +14,7 @@ import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager 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.MergePdfUseCase import com.chethan616.clearpdf.ui.utils.StarPromptEventBus import com.kyant.pdfcore.model.PdfDocument @@ -94,7 +95,7 @@ class MergePdfViewModel(private val mergePdfUseCase: MergePdfUseCase) : ViewMode fun onMerge(context: Context) { val uris = _uiState.value.selectedUris if (uris.size < 2) { - _uiState.value = _uiState.value.copy(errorMessage = "Select at least 2 PDFs") + _uiState.value = _uiState.value.copy(errorMessage = context.getString(R.string.merge_min_files)) return } _uiState.value = _uiState.value.copy(isMerging = true, errorMessage = null, resultMessage = null, lastOutputUri = null) @@ -118,7 +119,7 @@ class MergePdfViewModel(private val mergePdfUseCase: MergePdfUseCase) : ViewMode isMerging = false, lastOutputUri = outputUri, saveLocationLabel = saveLabel, - resultMessage = "Merged ${uris.size} files → $outName\nSaved to $saveLabel" + resultMessage = context.getString(R.string.merge_success, uris.size, outName, saveLabel) ) if (GitHubStarPromptManager.recordPdfInteraction(context)) { @@ -127,7 +128,7 @@ class MergePdfViewModel(private val mergePdfUseCase: MergePdfUseCase) : ViewMode } catch (e: Exception) { _uiState.value = _uiState.value.copy( isMerging = false, - errorMessage = e.message ?: "Merge failed" + errorMessage = context.getString(R.string.merge_failed) ) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt index 85ff7d4..d18a5e6 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt @@ -15,6 +15,7 @@ import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager 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.ui.utils.StarPromptEventBus import com.kyant.pdfcore.editor.PdfEditor import com.kyant.pdfcore.model.PdfDocument @@ -70,7 +71,7 @@ class PageOrganizerViewModel(private val editor: PdfEditor) : ViewModel() { pages = pages.mapIndexed { i, bmp -> OrganizerPage(originalIndex = i, thumbnail = bmp) } ) } catch (e: Exception) { - _uiState.value = PageOrganizerUiState(errorMessage = e.message ?: "Could not open PDF") + _uiState.value = PageOrganizerUiState(errorMessage = context.getString(R.string.organize_open_failed)) } } } @@ -119,12 +120,12 @@ class PageOrganizerViewModel(private val editor: PdfEditor) : ViewModel() { _uiState.value = _uiState.value.copy(pages = pages, resultMessage = null) } - fun deleteSelected() { + fun deleteSelected(context: Context) { val sel = _uiState.value.selectedIds if (sel.isEmpty()) return val pages = _uiState.value.pages.filterNot { it.originalIndex in sel } if (pages.isEmpty()) { - _uiState.value = _uiState.value.copy(errorMessage = "Can't delete every page") + _uiState.value = _uiState.value.copy(errorMessage = context.getString(R.string.organize_cannot_delete_all)) return } _uiState.value = _uiState.value.copy(pages = pages, selectedIds = emptySet(), resultMessage = null) @@ -176,12 +177,12 @@ class PageOrganizerViewModel(private val editor: PdfEditor) : ViewModel() { isSaving = false, lastOutputUri = outUri, saveLocationLabel = saveLabel, - resultMessage = "Saved ${pages.size} pages\nSaved to $saveLabel" + resultMessage = context.getString(R.string.organize_success, pages.size, saveLabel) ) val shouldPrompt = withContext(Dispatchers.IO) { GitHubStarPromptManager.recordPdfInteraction(context) } if (shouldPrompt) StarPromptEventBus.requestPrompt() } catch (e: Exception) { - _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = e.message ?: "Save failed") + _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = context.getString(R.string.organize_save_failed)) } } } 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 0c977d1..408c46a 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 @@ -19,13 +19,16 @@ import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager 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.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.kyant.pdfcore.model.PdfDocument +import com.kyant.pdfcore.security.PdfSecurityService import com.chethan616.clearpdf.ui.utils.AppDispatchers +import com.chethan616.clearpdf.utils.UniversalDocumentConverter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,6 +53,9 @@ data class PdfViewerUiState( 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, @@ -139,7 +145,7 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel private const val CACHE_RADIUS = 2 } - fun openPdf(context: Context, uri: Uri) { + fun openPdf(context: Context, uri: Uri, password: String? = null) { _uiState.value.document?.let { openPdfUseCase.close(it) } recycleBitmaps(_uiState.value.pageBitmaps) renderingPages.clear() @@ -148,6 +154,11 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel _uiState.value = _uiState.value.copy( isLoading = true, errorMessage = null, + document = null, + pageBitmaps = emptyList(), + passwordRequired = false, + passwordAttemptFailed = false, + passwordUri = null, ocrPagesInProgress = emptySet() ) viewModelScope.launch { @@ -161,8 +172,21 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel // Not all URI sources support persistable grants. } + val sourceUri = withContext(Dispatchers.IO) { + when { + password != null -> { + val decrypted = PdfSecurityService.decryptToCache(context, uri, password) + FileProvider.getUriForFile(context, "${context.packageName}.provider", decrypted) + } + UniversalDocumentConverter.isPdf(context, uri) && + PdfSecurityService.isPasswordProtected(context, uri) -> { + throw PdfSecurityService.PasswordRequiredException() + } + else -> uri + } + } val (doc, openedUri) = withContext(Dispatchers.IO) { - openDocumentWithFallback(context, uri) + openDocumentWithFallback(context, sourceUri) } val displayName = queryFileName(context, uri) ?: doc.name _uiState.value = _uiState.value.copy( @@ -170,6 +194,9 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel pageCount = doc.pageCount, currentPage = 0, isLoading = false, + passwordRequired = false, + passwordAttemptFailed = false, + passwordUri = null, document = doc, sizeBytes = doc.sizeBytes, pageBitmaps = List(doc.pageCount) { null }, @@ -196,10 +223,18 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel // Render first page renderPage(context, 0, DEFAULT_RENDER_WIDTH) + } catch (e: PdfSecurityService.PasswordRequiredException) { + _uiState.value = _uiState.value.copy( + isLoading = false, + passwordRequired = true, + passwordAttemptFailed = password != null, + passwordUri = uri, + errorMessage = null + ) } catch (e: Exception) { _uiState.value = _uiState.value.copy( isLoading = false, - errorMessage = e.message ?: "Failed to open PDF" + errorMessage = context.getString(R.string.viewer_open_failed) ) } } @@ -584,7 +619,7 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel _uiState.value = _uiState.value.copy( isExporting = false, - exportMessage = "Saved edited copy as $outputName", + exportMessage = context.getString(R.string.viewer_save_success, outputName), lastExportedUri = outputUri ) @@ -594,7 +629,7 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel } catch (e: Exception) { _uiState.value = _uiState.value.copy( isExporting = false, - exportError = e.message ?: "Failed to save edited PDF" + exportError = context.getString(R.string.viewer_save_failed) ) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SplitPdfViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SplitPdfViewModel.kt index 973cec9..e07f695 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SplitPdfViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/SplitPdfViewModel.kt @@ -15,6 +15,7 @@ import com.chethan616.clearpdf.data.repository.GitHubStarPromptManager 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.ui.utils.StarPromptEventBus import com.kyant.pdfcore.model.PdfDocument import com.kyant.pdfcore.splitter.PdfSplitter @@ -101,7 +102,7 @@ class SplitPdfViewModel(private val splitter: PdfSplitter) : ViewModel() { mode = SplitMode.EXTRACT_SELECTED ) } catch (e: Exception) { - _uiState.value = _uiState.value.copy(errorMessage = e.message) + _uiState.value = _uiState.value.copy(errorMessage = context.getString(R.string.open_pdf_failed)) } } } @@ -172,7 +173,7 @@ class SplitPdfViewModel(private val splitter: PdfSplitter) : ViewModel() { isSplitting = false, lastOutputUri = firstOutputUri, saveLocationLabel = saveLabel, - resultMessage = "Split into ${results.size} pages\nSaved to $saveLabel" + resultMessage = context.getString(R.string.split_success, results.size, saveLabel) ) if (GitHubStarPromptManager.recordPdfInteraction(context)) { @@ -181,7 +182,7 @@ class SplitPdfViewModel(private val splitter: PdfSplitter) : ViewModel() { } catch (e: Exception) { _uiState.value = _uiState.value.copy( isSplitting = false, - errorMessage = e.message ?: "Split failed" + errorMessage = context.getString(R.string.split_failed) ) } } @@ -206,7 +207,7 @@ class SplitPdfViewModel(private val splitter: PdfSplitter) : ViewModel() { isSplitting = false, lastOutputUri = outUri, saveLocationLabel = saveLabel, - resultMessage = "Extracted ${pages.size} pages\nSaved to $saveLabel" + resultMessage = context.getString(R.string.extract_success, pages.size, saveLabel) ) if (GitHubStarPromptManager.recordPdfInteraction(context)) { @@ -215,7 +216,7 @@ class SplitPdfViewModel(private val splitter: PdfSplitter) : ViewModel() { } catch (e: Exception) { _uiState.value = _uiState.value.copy( isSplitting = false, - errorMessage = e.message ?: "Extract failed" + errorMessage = context.getString(R.string.extract_failed) ) } } @@ -226,7 +227,7 @@ class SplitPdfViewModel(private val splitter: PdfSplitter) : ViewModel() { SplitMode.SPLIT_ALL -> onSplitAll(context) SplitMode.EXTRACT_SELECTED -> { if (_uiState.value.selectedPages.isEmpty()) { - _uiState.value = _uiState.value.copy(errorMessage = "Select at least one page to extract") + _uiState.value = _uiState.value.copy(errorMessage = context.getString(R.string.extract_min_page)) return } onExtractPages(context, _uiState.value.selectedPages) 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 01031a2..0e84dd5 100644 --- a/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt +++ b/app/src/main/java/com/chethan616/clearpdf/utils/UniversalDocumentConverter.kt @@ -15,6 +15,12 @@ import java.io.BufferedReader import java.io.File import java.io.FileOutputStream 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 { @@ -107,8 +113,20 @@ object UniversalDocumentConverter { private fun convertGenericToPdf(context: Context, sourceUri: Uri): Uri { val inputStream = context.contentResolver.openInputStream(sourceUri) ?: throw IllegalStateException("Unable to open file") - val bytes = inputStream.readBytes() - val contentStr = String(bytes, Charsets.UTF_8).filter { it.isISOControl().not() || it == '\n' || it == '\r' || it == '\t' } + 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() + } + val contentStr = if (structuredText.isNotEmpty()) { + structuredText.joinToString("\n") + } else { + String(bytes, Charsets.UTF_8).filter { it.isISOControl().not() || it == '\n' || it == '\r' || it == '\t' } + } val pdfDoc = PdfDocument() val pageWidth = 595 @@ -146,6 +164,73 @@ object UniversalDocumentConverter { return Uri.fromFile(outputFile) } + /** + * 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 -> + 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 } + } + event = parser.next() + } + } + zip.closeEntry() + if (lines.size >= 2000) break + } + } + return lines.take(2000) + } + + 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() } + } + }.toList() + } + fileName.endsWith(".ppt") -> HSLFSlideShow(input).use { slideshow -> + slideshow.slides.flatMap { slide -> + slide.shapes.mapNotNull { shape -> + (shape as? org.apache.poi.sl.usermodel.TextShape<*, *>)?.text + } + } + } + else -> emptyList() + }.filter { it.isNotBlank() }.take(2000) + } catch (_: Throwable) { + // Some malformed legacy files are still better served by the readable-byte fallback. + emptyList() + } + } + 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) diff --git a/app/src/main/res/drawable/ic_onboarding_formats.xml b/app/src/main/res/drawable/ic_onboarding_formats.xml new file mode 100644 index 0000000..356c6a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_formats.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_language.xml b/app/src/main/res/drawable/ic_onboarding_language.xml new file mode 100644 index 0000000..fab72d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_language.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_ready.xml b/app/src/main/res/drawable/ic_onboarding_ready.xml new file mode 100644 index 0000000..8b6da06 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_ready.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_tools.xml b/app/src/main/res/drawable/ic_onboarding_tools.xml new file mode 100644 index 0000000..e64426a --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_tools.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 180fcf0..1539f0b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -6,7 +6,7 @@ Abrir PDF Abrir um arquivo PDF Mesclar PDFs - Mesclar múltiplos PDFs + Mesclar múltiplos PDFs Comprimir Comprimir um arquivo PDF Criar PDF @@ -16,40 +16,42 @@ Bem-vindo ao ClearPDF Seu companheiro de documentos totalmente offline Escolha seu idioma - Personalize sua experiência + Personalize sua experiência + Idioma Abra qualquer arquivo - PDFs, documentos, planilhas, apresentações, imagens e muito mais + PDFs, documentos, planilhas, apresentações, imagens e muito mais Ferramentas poderosas Anote, assine, pesquise e organize seus documentos - Tudo pronto! 🎉 - O ClearPDF está pronto para usar.\nTodos os seus documentos, sempre privados. - 100% offline · Sem coleta de dados · Código aberto + Tudo pronto! 🎉 + O ClearPDF está pronto para usar.\nTodos os seus documentos, sempre privados. + 100% offline · Sem coleta de dados · Código aberto Continuar - Começar + Começar + %1$d de %2$d ClearPDF - ESPAÇO DE TRABALHO PDF + ESPAÇO DE TRABALHO PDF Trabalho com PDF, simplesmente elegante. - Abra, digitalize e organize seus arquivos — privativamente no seu dispositivo. + Abra, digitalize e organize seus arquivos — privativamente no seu dispositivo. Abrir PDF Digitalizar Recentes Ver todos Ver menos Nenhum arquivo recente - Abra um PDF para começar + Abra um PDF para começar NO DISPOSITIVO Ferramentas - Tudo que você precisa para trabalhar com PDFs. + Tudo que você precisa para trabalhar com PDFs. Abrir PDF Visualizar e ler Mesclar PDFs Combinar arquivos Dividir PDF - Extrair páginas + Extrair páginas Comprimir Reduzir tamanho do arquivo Organizar @@ -62,12 +64,12 @@ Em branco, imagens, texto - Configurações - Aparência + Configurações + Aparência Auto Claro Escuro - Segue as configurações do sistema + Segue as configurações do sistema Sempre usar tema claro Sempre usar tema escuro Local de Salvamento @@ -77,44 +79,44 @@ Comprimir Automaticamente Comprimir PDFs automaticamente ao importar Manter Original - Preservar arquivo original após edição - Notificações - Mostrar notificação quando tarefas concluírem - Qualidade de Compressão + 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 Idioma Escolha o idioma do aplicativo Sobre - Versão 1.1.0 - Feito por Chethan616 com ❤ + Versão 1.1.0 + Feito por Chethan616 com ❤ Favoritar no GitHub - ClearPDF é código aberto - Licenças de Código Aberto + ClearPDF é código aberto + Licenças de código aberto Visualizador de PDF Abrir um PDF Escolher um PDF - Selecione um arquivo PDF do seu dispositivo para visualizá-lo - Página %1$d / %2$d + Selecione um arquivo PDF do seu dispositivo para visualizá-lo + Página %1$d / %2$d Ferramentas de Desenho Selecionar Texto Adicionar Imagem Borracha - Salvar Edições + Salvar Edições Assinar Buscar - Buscar no documento… + Buscar no documento… %1$d de %2$d resultados Sem resultados Anterior - Próximo + Próximo Abrir outro PDF Caneta Destaque - Retângulo - Óvalo + Retângulo + Óvalo Linha Seta Desfazer @@ -122,12 +124,13 @@ Selecionar Tudo Sublinhado Tachado - Concluído + Concluído Nova Assinatura Substituir - Salvando PDF editado… + Salvando PDF editado… + Redefinir zoom %1$d%% Tamanho - Ir para a página + Ir para a página Desenhe sua assinatura @@ -135,21 +138,25 @@ Limpar Usar Assinatura Fina - Média + Média Espessa Forte + Assine aqui + Assinaturas salvas + Dê um nome para esta assinatura + Salvar nome Anotar e Assinar Desenhe, destaque e assine seus PDFs Buscar e Selecionar - Pesquise texto e selecione parágrafos inteiros + Pesquise texto e selecione parágrafos inteiros Ferramentas Completas Mescle, divida, comprima e converta Voltar - Concluído + Concluído Cancelar OK Copiar @@ -158,6 +165,197 @@ Abrir Salvar Dispensar - Agora não - Não perguntar novamente + Agora não + Não perguntar novamente + Diretório personalizado + Diretório padrão + Downloads / ClearPDF + 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 + Português + Abrir + Compartilhar + Detalhes + Remover + Compartilhar PDF + PDF + Pressione e segure para ações rápidas + Informações do arquivo + Páginas + Adicionado + Localização + Desconhecido + Agora + há %1$d min + há %1$d h + Ontem + %1$d KB + %1$.1f MB + %1$d B + %1$d páginas + Tamanho + Descriptografar PDF + Remover a senha de um PDF + Criptografar PDF + Proteger com uma senha + Digite a senha do PDF + Este documento está protegido. Digite a senha para continuar. + Senha + Desbloquear PDF + Essa senha não desbloqueou o PDF. Tente novamente. + Este PDF é protegido por senha. + Descriptografar PDF + Crie uma cópia desbloqueada sem alterar o original. + Selecionar PDF protegido + Senha do PDF + Salvar cópia desbloqueada + PDF desbloqueado salvo. + A senha está incorreta. + Ver PDF + Ver PDF criptografado + Ver PDF desbloqueado + Criptografar PDF + Crie uma cópia protegida por senha sem alterar o original. + Selecionar PDF para proteger + Criar senha do PDF + Confirmar senha + Salvar cópia criptografada + PDF criptografado salvo. + Digite uma senha para proteger o PDF. + As senhas não coincidem. + Este PDF já está protegido por senha. + Este tipo de arquivo ainda não pode ser visualizado. + Visualização de texto + Abrir documento + Página + Ir + OCR (%1$d) + Selecione dois ou mais PDFs para mesclar em um + Escolha um modo e execute uma ação principal clara. + %1$d arquivos selecionados + Reordene os arquivos antes de mesclar. De cima para baixo = ordem final. + Selecione pelo menos 2 PDFs para continuar. + Adicionar arquivos + Todos + Ímpares + Pares + Crie com digitalizações, imagens ou páginas em branco + Digitalize ou adicione imagens para iniciar seu rascunho. + Página %1$d + Toque nas setas para reordenar + Criar um PDF em branco + Defina o número de páginas e gere instantaneamente. + %1$d páginas + Texto avançado para PDF + Use para notas rápidas. Para documentos refinados, prefira digitalizar/imagem. + Digite o conteúdo... + %1$d caracteres + Importar imagens + Páginas em branco + Texto avançado + Criar PDF do rascunho + Criar PDF em branco + Criar PDF de texto + Adicionar imagens + Ajustar ao A4 + Tamanho original + Adicione fotos para criar um PDF. + Compartilhar texto + Nenhuma camada de texto neste PDF. + Escolha um PDF para extrair o texto. + Reordenar, girar e excluir páginas + Selecionar tudo + Girar + Arrastar + Início + Ferramentas + Configurações + Fechar + Anterior + Próximo + Desfazer + Alterar pasta + Pasta selecionada + Salvar documento + Nome do arquivo + Documento.pdf + Digitalizar documento + Detecte bordas, recorte e melhore seus documentos como em um scanner profissional. + Digitalizar documento + Scanner indisponível: %1$s + Importar da galeria + Recursos + Detecção e recorte automáticos de bordas + Correção de perspectiva + Digitalização de documentos com várias páginas + Importar da galeria + Exportar como PDF + %1$d páginas digitalizadas + 1 página digitalizada + Digitalizar mais + Adicionar da galeria + Limpar tudo + Filtro + Original + Automático + Escala de cinza + P&B + Vívido + Salvando... + Salvar como PDF + PDF salvo com sucesso! + Falha ao salvar o PDF + Tamanho: %1$d KB + Prévia da página %1$d + Página %1$d + Remover página + Imagem %1$d + PDF + Word + Excel + PowerPoint + Imagens + TXT + Apoie o ClearPDF + O ClearPDF é um projeto de código aberto no GitHub.\nVocê gostaria de favoritar o projeto? + Sim, favoritar + Agora não + Não perguntar novamente + Algo deu errado. Tente novamente. + Não foi possível abrir este PDF. + Não foi possível salvar o PDF. + Selecione pelo menos 2 PDFs + %1$d arquivos mesclados -> %2$s\nSalvo em %3$s + Falha ao mesclar. + Dividido em %1$d páginas\nSalvo em %2$s + %1$d páginas extraídas\nSalvo em %2$s + Selecione pelo menos uma página para extrair + Falha ao dividir. + Falha ao extrair. + Adicione pelo menos uma imagem + PDF de %1$d páginas criado\nSalvo em %2$s + Falha na conversão. + Selecione pelo menos uma imagem + O PDF em branco precisa de pelo menos 1 página + Digite algum texto + Não foi possível criar o arquivo de saída. + Não foi possível ler as imagens. + %1$s criado (%2$d páginas)\nSalvo em %3$s + Falha ao criar o PDF. + Comprimido: %1$dKB -> %2$dKB (%3$d%% menor)\nSalvo em %4$s + Falha ao comprimir. + Nenhum texto selecionável encontrado. Este PDF pode ser uma digitalização ou imagem - tente o OCR do scanner. + Falha ao extrair o texto. + Não foi possível abrir o PDF. + Não é possível excluir todas as páginas. + %1$d páginas salvas\nSalvo em %2$s + Falha ao salvar. + Falha ao abrir o PDF. + Cópia editada salva como %1$s + Falha ao salvar o PDF editado. + Organizar páginas + Imagens -> PDF + O scanner está indisponível neste contexto. + Não foi possível iniciar o scanner. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 180fcf0..87fcda8 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -6,7 +6,7 @@ Abrir PDF Abrir um arquivo PDF Mesclar PDFs - Mesclar múltiplos PDFs + Mesclar múltiplos PDFs Comprimir Comprimir um arquivo PDF Criar PDF @@ -16,40 +16,42 @@ Bem-vindo ao ClearPDF Seu companheiro de documentos totalmente offline Escolha seu idioma - Personalize sua experiência + Personalize sua experiência + Idioma Abra qualquer arquivo - PDFs, documentos, planilhas, apresentações, imagens e muito mais + PDFs, documentos, planilhas, apresentações, imagens e muito mais Ferramentas poderosas Anote, assine, pesquise e organize seus documentos - Tudo pronto! 🎉 - O ClearPDF está pronto para usar.\nTodos os seus documentos, sempre privados. - 100% offline · Sem coleta de dados · Código aberto + Tudo pronto! 🎉 + O ClearPDF está pronto para usar.\nTodos os seus documentos, sempre privados. + 100% offline · Sem coleta de dados · Código aberto Continuar - Começar + Começar + %1$d de %2$d ClearPDF - ESPAÇO DE TRABALHO PDF + ESPAÇO DE TRABALHO PDF Trabalho com PDF, simplesmente elegante. - Abra, digitalize e organize seus arquivos — privativamente no seu dispositivo. + Abra, digitalize e organize seus arquivos — privativamente no seu dispositivo. Abrir PDF Digitalizar Recentes Ver todos Ver menos Nenhum arquivo recente - Abra um PDF para começar + Abra um PDF para começar NO DISPOSITIVO Ferramentas - Tudo que você precisa para trabalhar com PDFs. + Tudo que você precisa para trabalhar com PDFs. Abrir PDF Visualizar e ler Mesclar PDFs Combinar arquivos Dividir PDF - Extrair páginas + Extrair páginas Comprimir Reduzir tamanho do arquivo Organizar @@ -62,12 +64,12 @@ Em branco, imagens, texto - Configurações - Aparência + Configurações + Aparência Auto Claro Escuro - Segue as configurações do sistema + Segue as configurações do sistema Sempre usar tema claro Sempre usar tema escuro Local de Salvamento @@ -77,44 +79,44 @@ Comprimir Automaticamente Comprimir PDFs automaticamente ao importar Manter Original - Preservar arquivo original após edição - Notificações - Mostrar notificação quando tarefas concluírem - Qualidade de Compressão + 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 Idioma Escolha o idioma do aplicativo Sobre - Versão 1.1.0 - Feito por Chethan616 com ❤ + Versão 1.1.0 + Feito por Chethan616 com ❤ Favoritar no GitHub - ClearPDF é código aberto - Licenças de Código Aberto + ClearPDF é código aberto + Licenças de código aberto Visualizador de PDF Abrir um PDF Escolher um PDF - Selecione um arquivo PDF do seu dispositivo para visualizá-lo - Página %1$d / %2$d + Selecione um arquivo PDF do seu dispositivo para visualizá-lo + Página %1$d / %2$d Ferramentas de Desenho Selecionar Texto Adicionar Imagem Borracha - Salvar Edições + Salvar Edições Assinar Buscar - Buscar no documento… + Buscar no documento… %1$d de %2$d resultados Sem resultados Anterior - Próximo + Próximo Abrir outro PDF Caneta Destaque - Retângulo - Óvalo + Retângulo + Óvalo Linha Seta Desfazer @@ -122,12 +124,13 @@ Selecionar Tudo Sublinhado Tachado - Concluído + Concluído Nova Assinatura Substituir - Salvando PDF editado… + Salvando PDF editado… + Redefinir zoom %1$d%% Tamanho - Ir para a página + Ir para a página Desenhe sua assinatura @@ -135,21 +138,25 @@ Limpar Usar Assinatura Fina - Média + Média Espessa Forte + Assine aqui + Assinaturas salvas + Dê um nome para esta assinatura + Salvar nome Anotar e Assinar Desenhe, destaque e assine seus PDFs Buscar e Selecionar - Pesquise texto e selecione parágrafos inteiros + Pesquise texto e selecione parágrafos inteiros Ferramentas Completas Mescle, divida, comprima e converta Voltar - Concluído + Concluído Cancelar OK Copiar @@ -158,6 +165,197 @@ Abrir Salvar Dispensar - Agora não - Não perguntar novamente + Agora não + Não perguntar novamente + Diretório personalizado + Diretório padrão + Downloads / ClearPDF + 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 + Português + Abrir + Compartilhar + Detalhes + Remover + Compartilhar PDF + PDF + Pressione e segure para ações rápidas + Informações do arquivo + Páginas + Adicionado + Localização + Desconhecido + Agora + há %1$d min + há %1$d h + Ontem + %1$d KB + %1$.1f MB + %1$d B + %1$d páginas + Tamanho + Descriptografar PDF + Remover a senha de um PDF + Criptografar PDF + Proteger com uma senha + Digite a senha do PDF + Este documento está protegido. Digite a senha para continuar. + Senha + Desbloquear PDF + Essa senha não desbloqueou o PDF. Tente novamente. + Este PDF é protegido por senha. + Descriptografar PDF + Crie uma cópia desbloqueada sem alterar o original. + Selecionar PDF protegido + Senha do PDF + Salvar cópia desbloqueada + PDF desbloqueado salvo. + A senha está incorreta. + Ver PDF + Ver PDF criptografado + Ver PDF desbloqueado + Criptografar PDF + Crie uma cópia protegida por senha sem alterar o original. + Selecionar PDF para proteger + Criar senha do PDF + Confirmar senha + Salvar cópia criptografada + PDF criptografado salvo. + Digite uma senha para proteger o PDF. + As senhas não coincidem. + Este PDF já está protegido por senha. + Este tipo de arquivo ainda não pode ser visualizado. + Visualização de texto + Abrir documento + Página + Ir + OCR (%1$d) + Selecione dois ou mais PDFs para mesclar em um + Escolha um modo e execute uma ação principal clara. + %1$d arquivos selecionados + Reordene os arquivos antes de mesclar. De cima para baixo = ordem final. + Selecione pelo menos 2 PDFs para continuar. + Adicionar arquivos + Todos + Ímpares + Pares + Crie com digitalizações, imagens ou páginas em branco + Digitalize ou adicione imagens para iniciar seu rascunho. + Página %1$d + Toque nas setas para reordenar + Criar um PDF em branco + Defina o número de páginas e gere instantaneamente. + %1$d páginas + Texto avançado para PDF + Use para notas rápidas. Para documentos refinados, prefira digitalizar/imagem. + Digite o conteúdo... + %1$d caracteres + Importar imagens + Páginas em branco + Texto avançado + Criar PDF do rascunho + Criar PDF em branco + Criar PDF de texto + Adicionar imagens + Ajustar ao A4 + Tamanho original + Adicione fotos para criar um PDF. + Compartilhar texto + Nenhuma camada de texto neste PDF. + Escolha um PDF para extrair o texto. + Reordenar, girar e excluir páginas + Selecionar tudo + Girar + Arrastar + Ińicio + Ferramentas + Configurações + Fechar + Anterior + Próximo + Desfazer + Alterar pasta + Pasta selecionada + Salvar documento + Nome do arquivo + Documento.pdf + Digitalizar documento + Detecte bordas, recorte e melhore seus documentos como em um scanner profissional. + Digitalizar documento + Scanner indisponível: %1$s + Importar da galeria + Recursos + Detecção e recorte automáticos de bordas + Correção de perspectiva + Digitalização de documentos com várias páginas + Importar da galeria + Exportar como PDF + %1$d páginas digitalizadas + 1 página digitalizada + Digitalizar mais + Adicionar da galeria + Limpar tudo + Filtro + Original + Automático + Escala de cinza + P&B + Vívido + Salvando... + Salvar como PDF + PDF salvo com sucesso! + Falha ao salvar o PDF + Tamanho: %1$d KB + Prévia da página %1$d + Página %1$d + Remover página + Imagem %1$d + PDF + Word + Excel + PowerPoint + Imagens + TXT + Apoie o ClearPDF + O ClearPDF é um projeto de código aberto no GitHub.\nVocê gostaria de favoritar o projeto? + Sim, favoritar + Agora não + Não perguntar novamente + Algo deu errado. Tente novamente. + Não foi possível abrir este PDF. + Não foi possível salvar o PDF. + Selecione pelo menos 2 PDFs + %1$d arquivos mesclados -> %2$s\nSalvo em %3$s + Falha ao mesclar. + Dividido em %1$d páginas\nSalvo em %2$s + %1$d páginas extraídas\nSalvo em %2$s + Selecione pelo menos uma página para extrair + Falha ao dividir. + Falha ao extrair. + Adicione pelo menos uma imagem + PDF de %1$d páginas criado\nSalvo em %2$s + Falha na conversão. + Selecione pelo menos uma imagem + O PDF em branco precisa de pelo menos 1 página + Digite algum texto + Não foi possível criar o arquivo de saída. + Não foi possível ler as imagens. + %1$s criado (%2$d páginas)\nSalvo em %3$s + Falha ao criar o PDF. + Comprimido: %1$dKB -> %2$dKB (%3$d%% menor)\nSalvo em %4$s + Falha ao comprimir. + Nenhum texto selecionável encontrado. Este PDF pode ser uma digitalização ou imagem - tente o OCR do scanner. + Falha ao extrair o texto. + Não foi possível abrir o PDF. + Não é possível excluir todas as páginas. + %1$d páginas salvas\nSalvo em %2$s + Falha ao salvar. + Falha ao abrir o PDF. + Cópia editada salva como %1$s + Falha ao salvar o PDF editado. + Organizar páginas + Imagens -> PDF + O scanner está indisponível neste contexto. + Não foi possível iniciar o scanner. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c092e9..2cefdd4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,18 +17,20 @@ Your fully offline document companion Choose your language Personalize your experience + Language Open anything PDFs, docs, sheets, slides, images & more Powerful tools Annotate, sign, search, and organize documents Continue Get Started + %1$d of %2$d ClearPDF PDF WORKSPACE PDF work, simply elegant. - Open, scan, and organize your files — privately on your device. + Open, scan, and organize your files — privately on your device. Open PDF Scan Recents @@ -84,7 +86,7 @@ Choose application language About Version 1.1.0 - Made by Chethan616 with ❤ + Made by Chethan616 with ❤ Star on GitHub ClearPDF is open source Open Source Licenses @@ -102,7 +104,7 @@ Save Edits Sign Find - Search in document… + Search in document… %1$d of %2$d results No results Previous @@ -122,9 +124,13 @@ Done New Sign Replace - Saving edited PDF… + Saving edited PDF… + Reset Zoom %1$d%% Size Jump to Page + Page + Go + OCR (%1$d) Draw your signature @@ -135,6 +141,10 @@ Medium Thick Heavy + Sign here + Saved Signatures + Name this signature + Save name Annotate & Sign @@ -143,9 +153,9 @@ Search text and select entire paragraphs Powerful Tools Merge, split, compress, and convert - You\'re all set! 🎉 + You\'re all set! 🎉 ClearPDF is ready to use.\nAll your documents, always private. - 100% offline · No data collection · Open source + 100% offline · No data collection · Open source Back @@ -160,4 +170,192 @@ Dismiss Not now Don\'t ask again + Custom Directory + Default Directory + Downloads / ClearPDF + Licensed under the Apache License, Version 2.0.\nYou may obtain a copy at apache.org/licenses/LICENSE-2.0 + by %1$s + English + Portuguese + Open + Share + Details + Remove + Share PDF + PDF + Long press for quick actions + File Info + Pages + Added + Location + Unknown + Just now + %1$d min ago + %1$d h ago + Yesterday + %1$d KB + %1$.1f MB + %1$d B + %1$d pages + Size + Decrypt PDF + Remove a PDF password + Encrypt PDF + Protect with a password + Enter PDF password + This document is protected. Enter its password to continue. + Password + Unlock PDF + That password didn\'t unlock the PDF. Try again. + This PDF is password protected. + Decrypt PDF + Create an unlocked copy without changing the original. + Select protected PDF + PDF password + Save unlocked copy + Unlocked PDF saved. + The password is incorrect. + View PDF + View encrypted PDF + View unlocked PDF + Encrypt PDF + Create a password-protected copy without changing the original. + Select PDF to protect + Create PDF password + Confirm password + Save encrypted copy + Encrypted PDF saved. + Enter a password to protect the PDF. + The passwords do not match. + This PDF is already password protected. + This file type cannot be previewed yet. + Text preview + Open document + Select two or more PDF files to merge into one + Choose a mode, then run one clear primary action. + %1$d files selected + Reorder files before merging. Top to bottom = output order. + Select at least 2 PDFs to continue. + Add Files + All + Odd + Even + Create with scans, images, or blank pages + Scan or add images to start your PDF draft. + Page %1$d + Tap arrows to reorder + Create a clean blank PDF + Set the number of pages and generate instantly. + %1$d pages + Advanced text to PDF + Use this for quick notes. For polished docs, prefer scan/image mode. + Type text content... + %1$d characters + Import images + Blank pages + Advanced text + Create PDF from draft + Create blank PDF + Create text PDF + Add images + Fit to A4 + Original size + Add photos to build a PDF. + Share text + No text layer in this PDF. + Pick a PDF to pull its text. + Reorder, rotate & delete pages + Select all + Rotate + Drag + Home + Tools + Settings + Close + Previous + Next + Undo + Change folder + Selected folder + Save document + File name + Document.pdf + Scan document + Auto-detect edges, crop and enhance your documents like a pro scanner. + Scan document + Scanner unavailable: %1$s + Import from gallery + Features + Auto edge detection and cropping + Perspective correction + Multi-page document scanning + Import from gallery + Export as PDF + %1$d pages scanned + 1 page scanned + Scan more + Add from gallery + Clear all + Filter + Original + Auto + Grayscale + B&W + Vivid + Saving... + Save as PDF + PDF saved successfully! + Failed to save PDF + Size: %1$d KB + Preview page %1$d + Page %1$d + Remove page + Image %1$d + PDF + Word + Excel + PowerPoint + Images + TXT + Support ClearPDF + ClearPDF is open source on GitHub.\nWould you like to star the project? + Yes, star it + Not now + Do not ask again + Something went wrong. Please try again. + Could not open this PDF. + Could not save the PDF. + Select at least 2 PDFs + Merged %1$d files -> %2$s\nSaved to %3$s + Merge failed. + Split into %1$d pages\nSaved to %2$s + Extracted %1$d pages\nSaved to %2$s + Select at least one page to extract + Split failed. + Extract failed. + Add at least one image + Created %1$d-page PDF\nSaved to %2$s + Conversion failed. + Please select at least one image + Blank PDF needs at least 1 page + Please enter some text + Failed to create the output file. + Could not decode any images. + Created %1$s (%2$d pages)\nSaved to %3$s + Failed to create PDF. + Compressed: %1$dKB -> %2$dKB (%3$d%% smaller)\nSaved to %4$s + Compression failed. + No selectable text found. This PDF may be a scan or image - try the scanner OCR instead. + Text extraction failed. + Could not open PDF. + Cannot delete every page. + Saved %1$d pages\nSaved to %2$s + Save failed. + Failed to open PDF. + Saved edited copy as %1$s + Failed to save edited PDF. + Organize pages + Images -> PDF + Scanner is unavailable in this context. + Could not start the scanner. diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt b/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt new file mode 100644 index 0000000..44a9c70 --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt @@ -0,0 +1,99 @@ +package com.kyant.pdfcore.security + +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.encryption.AccessPermission +import com.tom_roush.pdfbox.pdmodel.encryption.StandardProtectionPolicy +import java.io.File + +/** + * Password operations for PDFs. The service keeps the original URI untouched and performs + * all parsing/writing on the caller's worker thread. + */ +object PdfSecurityService { + + class PasswordRequiredException : IllegalStateException("PDF password required") + + fun isPasswordProtected(context: Context, uri: Uri): Boolean { + PdfBox.ensureInitialized(context) + return try { + context.contentResolver.openInputStream(uri)?.use { stream -> + PDDocument.load(stream).use { document -> document.isEncrypted } + } ?: false + } catch (error: Exception) { + if (isPasswordError(error)) true else throw error + } + } + + /** Creates an unlocked temporary PDF for the viewer without modifying the source. */ + fun decryptToCache(context: Context, uri: Uri, password: String): File { + PdfBox.ensureInitialized(context) + val outputDirectory = File(context.cacheDir, "decrypted_pdfs").apply { mkdirs() } + val output = File(outputDirectory, "unlocked_${System.currentTimeMillis()}.pdf") + try { + context.contentResolver.openInputStream(uri)?.use { input -> + PDDocument.load(input, password).use { document -> + document.setAllSecurityToBeRemoved(true) + output.outputStream().use { document.save(it) } + } + } ?: throw IllegalStateException("Unable to read PDF") + } catch (error: Exception) { + output.delete() + if (isPasswordError(error)) throw PasswordRequiredException() + throw error + } + return output + } + + /** Writes an unlocked copy to a user-selected destination URI. */ + fun decryptToUri(context: Context, sourceUri: Uri, destinationUri: Uri, password: String) { + PdfBox.ensureInitialized(context) + try { + context.contentResolver.openInputStream(sourceUri)?.use { input -> + PDDocument.load(input, password).use { document -> + document.setAllSecurityToBeRemoved(true) + context.contentResolver.openOutputStream(destinationUri)?.use { output -> + document.save(output) + } ?: throw IllegalStateException("Unable to write PDF") + } + } ?: throw IllegalStateException("Unable to read PDF") + } catch (error: Exception) { + if (isPasswordError(error)) throw PasswordRequiredException() + throw error + } + } + + /** Writes a password-protected copy to a user-selected destination URI. */ + fun encryptToUri(context: Context, sourceUri: Uri, destinationUri: Uri, password: String) { + require(password.isNotBlank()) { "PDF password required" } + PdfBox.ensureInitialized(context) + try { + context.contentResolver.openInputStream(sourceUri)?.use { input -> + PDDocument.load(input).use { document -> + val permissions = AccessPermission().apply { + setCanPrint(true) + setCanModify(false) + setCanExtractContent(true) + } + val policy = StandardProtectionPolicy(password, password, permissions).apply { + encryptionKeyLength = 128 + } + document.protect(policy) + context.contentResolver.openOutputStream(destinationUri)?.use { output -> + document.save(output) + } ?: throw IllegalStateException("Unable to write PDF") + } + } ?: throw IllegalStateException("Unable to read PDF") + } catch (error: Exception) { + if (isPasswordError(error)) throw PasswordRequiredException() + throw error + } + } + + private fun isPasswordError(error: Throwable): Boolean { + return generateSequence(error) { it.cause } + .any { it::class.java.simpleName.contains("InvalidPassword", ignoreCase = true) } + } +}