From 898ae7652633444d1b09da43bb10428eb18c8dae Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 13 Jul 2026 19:37:46 +0200 Subject: [PATCH] Internationalization: string resources + Dutch translation; v0.3.0 Move all user-facing strings into res/values/strings.xml (English base) and add res/values-nl/strings.xml (Dutch). Composables use stringResource; coroutine-set and non-composable strings (ViewModel, PaperlessClient) resolve via Context.getString. Mark app_name translatable=false. Bump to 0.3.0. Fixes #5 Co-Authored-By: Claude Opus 4.8 --- .claude/i18n.png | Bin 0 -> 24200 bytes app/build.gradle.kts | 4 +- .../java/eu/geyskens/pdfscan/ScanViewModel.kt | 12 ++- .../pdfscan/paperless/PaperlessClient.kt | 20 +++-- .../pdfscan/ui/screens/CameraScreen.kt | 11 +-- .../geyskens/pdfscan/ui/screens/CropScreen.kt | 14 ++-- .../pdfscan/ui/screens/ExportScreen.kt | 36 ++++----- .../pdfscan/ui/screens/PagesScreen.kt | 18 +++-- .../pdfscan/ui/screens/SettingsScreen.kt | 37 +++++----- app/src/main/res/values-nl/strings.xml | 69 ++++++++++++++++++ app/src/main/res/values/strings.xml | 69 +++++++++++++++++- 11 files changed, 224 insertions(+), 66 deletions(-) create mode 100644 .claude/i18n.png create mode 100644 app/src/main/res/values-nl/strings.xml diff --git a/.claude/i18n.png b/.claude/i18n.png new file mode 100644 index 0000000000000000000000000000000000000000..5daa3fada72b5bd3774cc0f91f2244d6dbadb59b GIT binary patch literal 24200 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 literal 0 HcmV?d00001 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 466c03f..b6ef93c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "eu.geyskens.pdfscan" minSdk = 26 targetSdk = 35 - versionCode = 4 - versionName = "0.2.0" + versionCode = 5 + versionName = "0.3.0" // OpenCV native libs are large; ship only the ABIs real phones use. ndk { diff --git a/app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt b/app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt index 6abf092..0099a69 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt @@ -115,9 +115,11 @@ class ScanViewModel(app: Application) : AndroidViewModel(app) { val s = settings.value val token = settingsRepo.getToken() if (s.paperlessUrl.isBlank() || token.isNullOrBlank()) { - return PaperlessClient.Result.Failure("Paperless isn't set up yet (URL + token).") + return PaperlessClient.Result.Failure( + getApplication().getString(R.string.err_paperless_not_setup) + ) } - return PaperlessClient(s.paperlessUrl, token).upload(pdf, title) + return PaperlessClient(s.paperlessUrl, token, getApplication()).upload(pdf, title) } // --- Settings pass-throughs (SettingsScreen) --- @@ -133,9 +135,11 @@ class ScanViewModel(app: Application) : AndroidViewModel(app) { suspend fun testPaperless(url: String, token: String): PaperlessClient.Result { if (url.isBlank() || token.isBlank()) { - return PaperlessClient.Result.Failure("Enter a URL and token first.") + return PaperlessClient.Result.Failure( + getApplication().getString(R.string.err_enter_url_token) + ) } - return PaperlessClient(url.trim(), token.trim()).testConnection() + return PaperlessClient(url.trim(), token.trim(), getApplication()).testConnection() } fun clearSession() { diff --git a/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt b/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt index 270a5b9..1c05d80 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt @@ -1,5 +1,7 @@ package eu.geyskens.pdfscan.paperless +import android.content.Context +import eu.geyskens.pdfscan.R import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay @@ -22,6 +24,7 @@ import java.util.concurrent.TimeUnit class PaperlessClient( private val baseUrl: String, private val token: String, + private val context: Context, ) { private val client = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) @@ -53,11 +56,12 @@ class PaperlessClient( client.newCall(request).execute().use { resp -> when { resp.isSuccessful -> Result.Success - resp.code == 401 || resp.code == 403 -> Result.Failure("Invalid token (${resp.code}).") - else -> Result.Failure("Server responded with ${resp.code}.") + resp.code == 401 || resp.code == 403 -> + Result.Failure(context.getString(R.string.err_invalid_token, resp.code)) + else -> Result.Failure(context.getString(R.string.err_server_responded, resp.code)) } } - }.getOrElse { Result.Failure(it.message ?: "Connection failed.") } + }.getOrElse { Result.Failure(it.message ?: context.getString(R.string.err_connection_failed)) } } /** @@ -85,12 +89,16 @@ class PaperlessClient( val posted: kotlin.Result = withContext(NonCancellable) { runCatching { client.newCall(request).execute().use { resp -> - if (!resp.isSuccessful) error("Upload failed: ${resp.code} ${resp.message}") + if (!resp.isSuccessful) { + error(context.getString(R.string.err_upload_failed_code, resp.code, resp.message)) + } resp.body?.string()?.trim()?.trim('"')?.takeIf { it.isNotBlank() } } } } - val taskId = posted.getOrElse { return@withContext Result.Failure(it.message ?: "Upload failed.") } + val taskId = posted.getOrElse { + return@withContext Result.Failure(it.message ?: context.getString(R.string.err_upload_failed)) + } // Older servers may not return a usable id; then we can only confirm acceptance. if (taskId == null || taskId.equals("OK", ignoreCase = true)) return@withContext Result.Pending @@ -119,7 +127,7 @@ class PaperlessClient( "FAILURE" -> { val msg = task.result.orEmpty() return if (msg.contains("duplicate", ignoreCase = true)) Result.Duplicate - else Result.Failure(msg.ifBlank { "Paperless could not process the document." }) + else Result.Failure(msg.ifBlank { context.getString(R.string.err_paperless_process) }) } // PENDING / STARTED / RETRY / unknown → keep waiting. } diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt index e6286cb..89e3650 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.concurrent.futures.await @@ -58,6 +59,7 @@ import androidx.core.content.ContextCompat import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import eu.geyskens.pdfscan.R import eu.geyskens.pdfscan.Routes import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.scan.DocumentAnalyzer @@ -84,13 +86,12 @@ private fun PermissionRequest(onRequest: () -> Unit) { Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - "PaperScan needs camera access to scan documents. " + - "Nothing is sent to the cloud.", + stringResource(R.string.camera_permission_rationale), color = Color.White, textAlign = TextAlign.Center, ) androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp)) - Button(onClick = onRequest) { Text("Grant access") } + Button(onClick = onRequest) { Text(stringResource(R.string.grant_access)) } } } } @@ -198,7 +199,7 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation. onClick = { navController.navigate(Routes.SETTINGS) }, modifier = Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(8.dp), ) { - Icon(Icons.Filled.Settings, contentDescription = "Settings", tint = Color.White) + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings), tint = Color.White) } // Bottom controls. @@ -259,7 +260,7 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation. ) { Icon( Icons.Filled.Check, - contentDescription = "Done", + contentDescription = stringResource(R.string.done), tint = if (pages.isEmpty()) Color.Gray else Color.White, ) } diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt index 41b6c1e..5ce2a28 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt @@ -34,9 +34,11 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import eu.geyskens.pdfscan.R import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.data.ScanFilter import androidx.compose.foundation.Canvas @@ -135,9 +137,9 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String) Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - FilterOption("Color", filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR } - FilterOption("Grayscale", filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE } - FilterOption("Black & white", filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE } + FilterOption(stringResource(R.string.filter_color), filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR } + FilterOption(stringResource(R.string.filter_grayscale), filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE } + FilterOption(stringResource(R.string.filter_bw), filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE } } Row( @@ -145,7 +147,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String) horizontalArrangement = Arrangement.spacedBy(8.dp), ) { OutlinedButton(onClick = { vm.rotate(pageId) }, modifier = Modifier.weight(1f)) { - Text("Rotate") + Text(stringResource(R.string.rotate)) } OutlinedButton( onClick = { @@ -153,7 +155,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String) full.forEachIndexed { i, p -> corners[i].value = PointF(p.x, p.y) } }, modifier = Modifier.weight(1f), - ) { Text("Reset") } + ) { Text(stringResource(R.string.reset)) } Button( onClick = { vm.updateCorners(pageId, corners.map { PointF(it.value.x, it.value.y) }) @@ -161,7 +163,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String) navController.popBackStack() }, modifier = Modifier.weight(1f), - ) { Text("Apply") } + ) { Text(stringResource(R.string.apply)) } } } } diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt index c9a7dba..e76ecae 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt @@ -36,8 +36,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import eu.geyskens.pdfscan.R import eu.geyskens.pdfscan.Routes import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.paperless.PaperlessClient @@ -70,7 +72,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) { fun mode() = if (a4) PageSizeMode.A4 else PageSizeMode.ORIGINAL Scaffold( - topBar = { TopAppBar(title = { Text("Export (${pages.size} pages)") }) }, + topBar = { TopAppBar(title = { Text(stringResource(R.string.export_title, pages.size)) }) }, ) { padding -> Column( Modifier.fillMaxSize().padding(padding).padding(16.dp), @@ -79,7 +81,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) { OutlinedTextField( value = fileName, onValueChange = { fileName = it }, - label = { Text("File name") }, + label = { Text(stringResource(R.string.file_name)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -89,12 +91,12 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) { selected = a4, onClick = { a4 = true }, shape = SegmentedButtonDefaults.itemShape(0, 2), - ) { Text("A4 page") } + ) { Text(stringResource(R.string.page_size_a4)) } SegmentedButton( selected = !a4, onClick = { a4 = false }, shape = SegmentedButtonDefaults.itemShape(1, 2), - ) { Text("Original size") } + ) { Text(stringResource(R.string.page_size_original)) } } Button( @@ -113,9 +115,9 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) { putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - context.startActivity(Intent.createChooser(share, "Share PDF")) + context.startActivity(Intent.createChooser(share, context.getString(R.string.share_pdf))) } catch (e: Exception) { - status = "Export failed: ${e.message}" + status = context.getString(R.string.export_failed, e.message ?: "") } finally { busy = false } @@ -126,7 +128,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) { ) { Icon(Icons.Filled.Share, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text("Share / save") + Text(stringResource(R.string.share_save)) } Button( @@ -140,19 +142,19 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) { val pdf = vm.exportPdf(fileName, mode()) when (val r = vm.uploadToPaperless(pdf, fileName)) { is PaperlessClient.Result.Success -> - status = "Added to Paperless ✓" + status = context.getString(R.string.upload_added) is PaperlessClient.Result.Duplicate -> - status = "Already in Paperless (duplicate) — not added again." + status = context.getString(R.string.upload_duplicate) is PaperlessClient.Result.Pending -> - status = "Uploaded — Paperless is still processing…" + status = context.getString(R.string.upload_pending) is PaperlessClient.Result.Failure -> status = r.message } } catch (e: CancellationException) { // User tapped "Don't wait": the document was already sent. - status = "Uploaded — Paperless will finish processing in the background." + status = context.getString(R.string.upload_background) } catch (e: Exception) { - status = "Error: ${e.message}" + status = context.getString(R.string.error_generic, e.message ?: "") } finally { busy = false uploading = false @@ -164,29 +166,29 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) { ) { Icon(Icons.Filled.CloudUpload, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text("Upload naar Paperless") + Text(stringResource(R.string.upload_to_paperless)) } if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) { Text( - "Configure Paperless (server URL + token) in settings first to upload.", + stringResource(R.string.paperless_not_configured), style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = Color(0xFF9A9A9A), ) OutlinedButton( onClick = { navController.navigate(Routes.SETTINGS) }, modifier = Modifier.fillMaxWidth(), - ) { Text("Set up Paperless") } + ) { Text(stringResource(R.string.set_up_paperless)) } } if (busy) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { CircularProgressIndicator(Modifier.height(24.dp)) Spacer(Modifier.width(12.dp)) - Text(if (uploading) "Uploading…" else "Working…") + Text(if (uploading) stringResource(R.string.uploading) else stringResource(R.string.working)) if (uploading) { Spacer(Modifier.width(12.dp)) - OutlinedButton(onClick = { uploadJob?.cancel() }) { Text("Don't wait") } + OutlinedButton(onClick = { uploadJob?.cancel() }) { Text(stringResource(R.string.dont_wait)) } } } } diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt index b9c1660..42ff279 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt @@ -39,8 +39,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import eu.geyskens.pdfscan.R import eu.geyskens.pdfscan.Routes import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.data.Page @@ -53,12 +55,12 @@ fun PagesScreen(vm: ScanViewModel, navController: NavController) { Scaffold( topBar = { TopAppBar( - title = { Text("Pages (${pages.size})") }, + title = { Text(stringResource(R.string.pages_title, pages.size)) }, actions = { IconButton( onClick = { if (pages.isNotEmpty()) navController.navigate(Routes.EXPORT) }, ) { - Icon(Icons.Filled.PictureAsPdf, contentDescription = "Export PDF") + Icon(Icons.Filled.PictureAsPdf, contentDescription = stringResource(R.string.export_pdf)) } }, ) @@ -67,7 +69,7 @@ fun PagesScreen(vm: ScanViewModel, navController: NavController) { ExtendedFloatingActionButton( onClick = { navController.navigate(Routes.CAMERA) }, icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - text = { Text("Add page") }, + text = { Text(stringResource(R.string.add_page)) }, ) }, ) { padding -> @@ -122,24 +124,24 @@ private fun PageRow( bitmap?.let { Image( bitmap = it.asImageBitmap(), - contentDescription = "Page ${index + 1}", + contentDescription = stringResource(R.string.page_number, index + 1), contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) } } Spacer(Modifier.width(12.dp)) - Text("Page ${index + 1}", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.page_number, index + 1), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) Column { IconButton(onClick = onUp, enabled = !isFirst) { - Icon(Icons.Filled.ArrowUpward, contentDescription = "Move up") + Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up)) } IconButton(onClick = onDown, enabled = !isLast) { - Icon(Icons.Filled.ArrowDownward, contentDescription = "Move down") + Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down)) } } IconButton(onClick = onDelete) { - Icon(Icons.Filled.Delete, contentDescription = "Delete", tint = Color(0xFFD32F2F)) + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete), tint = Color(0xFFD32F2F)) } } } diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt index e0a963d..b2d6853 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt @@ -35,9 +35,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import eu.geyskens.pdfscan.R import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.data.ScanFilter import eu.geyskens.pdfscan.paperless.PaperlessClient @@ -47,6 +50,7 @@ import kotlinx.coroutines.launch @Composable fun SettingsScreen(vm: ScanViewModel, navController: NavController) { val scope = rememberCoroutineScope() + val context = LocalContext.current val settings by vm.settings.collectAsState() var url by remember { mutableStateOf(settings.paperlessUrl) } @@ -59,10 +63,10 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) { snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text("Settings") }, + title = { Text(stringResource(R.string.settings)) }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -76,27 +80,26 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) { .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text("Paperless-ngx", style = androidx.compose.material3.MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.paperless_header), style = androidx.compose.material3.MaterialTheme.typography.titleMedium) OutlinedTextField( value = url, onValueChange = { url = it; testResult = null }, - label = { Text("Server URL (e.g. https://paperless.home.lan)") }, + label = { Text(stringResource(R.string.server_url_label)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = token, onValueChange = { token = it; testResult = null }, - label = { Text("API token") }, + label = { Text(stringResource(R.string.api_token_label)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), ) if (url.startsWith("http://")) { Text( - "⚠ You're using http:// — your token and documents travel unencrypted over the " + - "network. Use https:// when you can.", + stringResource(R.string.http_warning), color = Color(0xFFB26A00), style = androidx.compose.material3.MaterialTheme.typography.bodySmall, ) @@ -109,55 +112,55 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) { testResult = null scope.launch { testResult = when (val r = vm.testPaperless(url, token)) { - is PaperlessClient.Result.Success -> "Connection OK ✓" + is PaperlessClient.Result.Success -> context.getString(R.string.connection_ok) is PaperlessClient.Result.Failure -> r.message - else -> "Unexpected response from the server." + else -> context.getString(R.string.unexpected_response) } testing = false } }, enabled = !testing, modifier = Modifier.weight(1f), - ) { Text("Test connection") } + ) { Text(stringResource(R.string.test_connection)) } Button( onClick = { scope.launch { vm.savePaperless(url, token) - snackbarHostState.showSnackbar("Settings saved ✓") + snackbarHostState.showSnackbar(context.getString(R.string.settings_saved)) } }, modifier = Modifier.weight(1f), - ) { Text("Save") } + ) { Text(stringResource(R.string.save)) } } if (testing) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(Modifier.width(20.dp)) Spacer(Modifier.width(8.dp)) - Text("Testing…") + Text(stringResource(R.string.testing)) } } testResult?.let { Text(it, color = Color(0xFF1F6FEB)) } Text( - "Default filter", + stringResource(R.string.default_filter), style = androidx.compose.material3.MaterialTheme.typography.titleMedium, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = settings.defaultFilter == ScanFilter.COLOR, onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.COLOR) } }, - label = { Text("Color") }, + label = { Text(stringResource(R.string.filter_color)) }, ) FilterChip( selected = settings.defaultFilter == ScanFilter.GRAYSCALE, onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.GRAYSCALE) } }, - label = { Text("Grayscale") }, + label = { Text(stringResource(R.string.filter_grayscale)) }, ) FilterChip( selected = settings.defaultFilter == ScanFilter.BLACK_WHITE, onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.BLACK_WHITE) } }, - label = { Text("Black & white") }, + label = { Text(stringResource(R.string.filter_bw)) }, ) } } diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..4f32ada --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,69 @@ + + + + PaperScan heeft cameratoegang nodig om documenten te scannen. Er wordt niets naar de cloud gestuurd. + Toegang geven + Instellingen + Klaar + + + Pagina\'s (%1$d) + Pagina toevoegen + Exporteer PDF + Pagina %1$d + Omhoog + Omlaag + Verwijderen + + + Kleur + Grijs + Zwart-wit + Draaien + Herstel + Toepassen + + + Exporteren (%1$d pagina\'s) + Bestandsnaam + A4-pagina + Originele grootte + Delen / opslaan + PDF delen + Upload naar Paperless + Paperless instellen + Configureer eerst Paperless (server-URL + token) in de instellingen om te kunnen uploaden. + Uploaden… + Bezig… + Niet wachten + Fout bij exporteren: %1$s + Fout: %1$s + Toegevoegd aan Paperless ✓ + Al aanwezig in Paperless (duplicaat) — niet opnieuw toegevoegd. + Geüpload — Paperless is het nog aan het verwerken… + Geüpload — Paperless verwerkt het verder op de achtergrond. + + + Terug + Paperless-ngx + Server-URL (bv. https://paperless.thuis.lan) + API-token + ⚠ Je gebruikt http:// — je token en documenten gaan onversleuteld over het netwerk. Gebruik https:// als het even kan. + Verbinding testen + Opslaan + Testen… + Instellingen opgeslagen ✓ + Verbinding OK ✓ + Onbekend antwoord van de server. + Standaardfilter + + + Ongeldig token (%1$d). + Server antwoordde met %1$d. + Verbinding mislukt. + Upload mislukt: %1$d %2$s + Upload mislukt. + Paperless kon het document niet verwerken. + Paperless is nog niet ingesteld (URL + token). + Vul eerst URL en token in. + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 007b46d..8f6122f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,71 @@ - PaperScan + PaperScan + + + PaperScan needs camera access to scan documents. Nothing is sent to the cloud. + Grant access + Settings + Done + + + Pages (%1$d) + Add page + Export PDF + Page %1$d + Move up + Move down + Delete + + + Color + Grayscale + Black & white + Rotate + Reset + Apply + + + Export (%1$d pages) + File name + A4 page + Original size + Share / save + Share PDF + Upload to Paperless + Set up Paperless + Configure Paperless (server URL + token) in settings first to upload. + Uploading… + Working… + Don\'t wait + Export failed: %1$s + Error: %1$s + Added to Paperless ✓ + Already in Paperless (duplicate) — not added again. + Uploaded — Paperless is still processing… + Uploaded — Paperless will finish processing in the background. + + + Back + Paperless-ngx + Server URL (e.g. https://paperless.home.lan) + API token + ⚠ You\'re using http:// — your token and documents travel unencrypted over the network. Use https:// when you can. + Test connection + Save + Testing… + Settings saved ✓ + Connection OK ✓ + Unexpected response from the server. + Default filter + + + Invalid token (%1$d). + Server responded with %1$d. + Connection failed. + Upload failed: %1$d %2$s + Upload failed. + Paperless could not process the document. + Paperless isn\'t set up yet (URL + token). + Enter a URL and token first.