1 Commits

Author SHA1 Message Date
sam
898ae76526 Internationalization: string resources + Dutch translation; v0.3.0
All checks were successful
Build APK / build (push) Successful in 5m55s
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 <noreply@anthropic.com>
2026-07-13 19:37:46 +02:00
11 changed files with 224 additions and 66 deletions

BIN
.claude/i18n.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -24,8 +24,8 @@ android {
applicationId = "eu.geyskens.pdfscan" applicationId = "eu.geyskens.pdfscan"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 4 versionCode = 5
versionName = "0.2.0" versionName = "0.3.0"
// OpenCV native libs are large; ship only the ABIs real phones use. // OpenCV native libs are large; ship only the ABIs real phones use.
ndk { ndk {

View File

@@ -115,9 +115,11 @@ class ScanViewModel(app: Application) : AndroidViewModel(app) {
val s = settings.value val s = settings.value
val token = settingsRepo.getToken() val token = settingsRepo.getToken()
if (s.paperlessUrl.isBlank() || token.isNullOrBlank()) { if (s.paperlessUrl.isBlank() || token.isNullOrBlank()) {
return PaperlessClient.Result.Failure("Paperless isn't set up yet (URL + token).") return PaperlessClient.Result.Failure(
getApplication<Application>().getString(R.string.err_paperless_not_setup)
)
} }
return PaperlessClient(s.paperlessUrl, token).upload(pdf, title) return PaperlessClient(s.paperlessUrl, token, getApplication<Application>()).upload(pdf, title)
} }
// --- Settings pass-throughs (SettingsScreen) --- // --- Settings pass-throughs (SettingsScreen) ---
@@ -133,9 +135,11 @@ class ScanViewModel(app: Application) : AndroidViewModel(app) {
suspend fun testPaperless(url: String, token: String): PaperlessClient.Result { suspend fun testPaperless(url: String, token: String): PaperlessClient.Result {
if (url.isBlank() || token.isBlank()) { if (url.isBlank() || token.isBlank()) {
return PaperlessClient.Result.Failure("Enter a URL and token first.") return PaperlessClient.Result.Failure(
getApplication<Application>().getString(R.string.err_enter_url_token)
)
} }
return PaperlessClient(url.trim(), token.trim()).testConnection() return PaperlessClient(url.trim(), token.trim(), getApplication<Application>()).testConnection()
} }
fun clearSession() { fun clearSession() {

View File

@@ -1,5 +1,7 @@
package eu.geyskens.pdfscan.paperless package eu.geyskens.pdfscan.paperless
import android.content.Context
import eu.geyskens.pdfscan.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -22,6 +24,7 @@ import java.util.concurrent.TimeUnit
class PaperlessClient( class PaperlessClient(
private val baseUrl: String, private val baseUrl: String,
private val token: String, private val token: String,
private val context: Context,
) { ) {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
@@ -53,11 +56,12 @@ class PaperlessClient(
client.newCall(request).execute().use { resp -> client.newCall(request).execute().use { resp ->
when { when {
resp.isSuccessful -> Result.Success resp.isSuccessful -> Result.Success
resp.code == 401 || resp.code == 403 -> Result.Failure("Invalid token (${resp.code}).") resp.code == 401 || resp.code == 403 ->
else -> Result.Failure("Server responded with ${resp.code}.") 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<String?> = withContext(NonCancellable) { val posted: kotlin.Result<String?> = withContext(NonCancellable) {
runCatching { runCatching {
client.newCall(request).execute().use { resp -> 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() } 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. // 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 if (taskId == null || taskId.equals("OK", ignoreCase = true)) return@withContext Result.Pending
@@ -119,7 +127,7 @@ class PaperlessClient(
"FAILURE" -> { "FAILURE" -> {
val msg = task.result.orEmpty() val msg = task.result.orEmpty()
return if (msg.contains("duplicate", ignoreCase = true)) Result.Duplicate 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. // PENDING / STARTED / RETRY / unknown → keep waiting.
} }

View File

@@ -51,6 +51,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.concurrent.futures.await 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.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.Routes import eu.geyskens.pdfscan.Routes
import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.scan.DocumentAnalyzer import eu.geyskens.pdfscan.scan.DocumentAnalyzer
@@ -84,13 +86,12 @@ private fun PermissionRequest(onRequest: () -> Unit) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) { androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
"PaperScan needs camera access to scan documents. " + stringResource(R.string.camera_permission_rationale),
"Nothing is sent to the cloud.",
color = Color.White, color = Color.White,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp)) 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) }, onClick = { navController.navigate(Routes.SETTINGS) },
modifier = Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(8.dp), 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. // Bottom controls.
@@ -259,7 +260,7 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
) { ) {
Icon( Icon(
Icons.Filled.Check, Icons.Filled.Check,
contentDescription = "Done", contentDescription = stringResource(R.string.done),
tint = if (pages.isEmpty()) Color.Gray else Color.White, tint = if (pages.isEmpty()) Color.Gray else Color.White,
) )
} }

View File

@@ -34,9 +34,11 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.data.ScanFilter import eu.geyskens.pdfscan.data.ScanFilter
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@@ -135,9 +137,9 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
Modifier.fillMaxWidth().padding(12.dp), Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
FilterOption("Color", filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR } FilterOption(stringResource(R.string.filter_color), filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR }
FilterOption("Grayscale", filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE } FilterOption(stringResource(R.string.filter_grayscale), filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE }
FilterOption("Black & white", filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE } FilterOption(stringResource(R.string.filter_bw), filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE }
} }
Row( Row(
@@ -145,7 +147,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
OutlinedButton(onClick = { vm.rotate(pageId) }, modifier = Modifier.weight(1f)) { OutlinedButton(onClick = { vm.rotate(pageId) }, modifier = Modifier.weight(1f)) {
Text("Rotate") Text(stringResource(R.string.rotate))
} }
OutlinedButton( OutlinedButton(
onClick = { 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) } full.forEachIndexed { i, p -> corners[i].value = PointF(p.x, p.y) }
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Reset") } ) { Text(stringResource(R.string.reset)) }
Button( Button(
onClick = { onClick = {
vm.updateCorners(pageId, corners.map { PointF(it.value.x, it.value.y) }) 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() navController.popBackStack()
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Apply") } ) { Text(stringResource(R.string.apply)) }
} }
} }
} }

View File

@@ -36,8 +36,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.Routes import eu.geyskens.pdfscan.Routes
import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.paperless.PaperlessClient 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 fun mode() = if (a4) PageSizeMode.A4 else PageSizeMode.ORIGINAL
Scaffold( Scaffold(
topBar = { TopAppBar(title = { Text("Export (${pages.size} pages)") }) }, topBar = { TopAppBar(title = { Text(stringResource(R.string.export_title, pages.size)) }) },
) { padding -> ) { padding ->
Column( Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp), Modifier.fillMaxSize().padding(padding).padding(16.dp),
@@ -79,7 +81,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
OutlinedTextField( OutlinedTextField(
value = fileName, value = fileName,
onValueChange = { fileName = it }, onValueChange = { fileName = it },
label = { Text("File name") }, label = { Text(stringResource(R.string.file_name)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -89,12 +91,12 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
selected = a4, selected = a4,
onClick = { a4 = true }, onClick = { a4 = true },
shape = SegmentedButtonDefaults.itemShape(0, 2), shape = SegmentedButtonDefaults.itemShape(0, 2),
) { Text("A4 page") } ) { Text(stringResource(R.string.page_size_a4)) }
SegmentedButton( SegmentedButton(
selected = !a4, selected = !a4,
onClick = { a4 = false }, onClick = { a4 = false },
shape = SegmentedButtonDefaults.itemShape(1, 2), shape = SegmentedButtonDefaults.itemShape(1, 2),
) { Text("Original size") } ) { Text(stringResource(R.string.page_size_original)) }
} }
Button( Button(
@@ -113,9 +115,9 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 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) { } catch (e: Exception) {
status = "Export failed: ${e.message}" status = context.getString(R.string.export_failed, e.message ?: "")
} finally { } finally {
busy = false busy = false
} }
@@ -126,7 +128,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
) { ) {
Icon(Icons.Filled.Share, contentDescription = null) Icon(Icons.Filled.Share, contentDescription = null)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Share / save") Text(stringResource(R.string.share_save))
} }
Button( Button(
@@ -140,19 +142,19 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
val pdf = vm.exportPdf(fileName, mode()) val pdf = vm.exportPdf(fileName, mode())
when (val r = vm.uploadToPaperless(pdf, fileName)) { when (val r = vm.uploadToPaperless(pdf, fileName)) {
is PaperlessClient.Result.Success -> is PaperlessClient.Result.Success ->
status = "Added to Paperless ✓" status = context.getString(R.string.upload_added)
is PaperlessClient.Result.Duplicate -> is PaperlessClient.Result.Duplicate ->
status = "Already in Paperless (duplicate) — not added again." status = context.getString(R.string.upload_duplicate)
is PaperlessClient.Result.Pending -> is PaperlessClient.Result.Pending ->
status = "Uploaded — Paperless is still processing…" status = context.getString(R.string.upload_pending)
is PaperlessClient.Result.Failure -> is PaperlessClient.Result.Failure ->
status = r.message status = r.message
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
// User tapped "Don't wait": the document was already sent. // 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) { } catch (e: Exception) {
status = "Error: ${e.message}" status = context.getString(R.string.error_generic, e.message ?: "")
} finally { } finally {
busy = false busy = false
uploading = false uploading = false
@@ -164,29 +166,29 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
) { ) {
Icon(Icons.Filled.CloudUpload, contentDescription = null) Icon(Icons.Filled.CloudUpload, contentDescription = null)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Upload naar Paperless") Text(stringResource(R.string.upload_to_paperless))
} }
if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) { if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) {
Text( Text(
"Configure Paperless (server URL + token) in settings first to upload.", stringResource(R.string.paperless_not_configured),
style = androidx.compose.material3.MaterialTheme.typography.bodySmall, style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color = Color(0xFF9A9A9A), color = Color(0xFF9A9A9A),
) )
OutlinedButton( OutlinedButton(
onClick = { navController.navigate(Routes.SETTINGS) }, onClick = { navController.navigate(Routes.SETTINGS) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Set up Paperless") } ) { Text(stringResource(R.string.set_up_paperless)) }
} }
if (busy) { if (busy) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(24.dp)) CircularProgressIndicator(Modifier.height(24.dp))
Spacer(Modifier.width(12.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) { if (uploading) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
OutlinedButton(onClick = { uploadJob?.cancel() }) { Text("Don't wait") } OutlinedButton(onClick = { uploadJob?.cancel() }) { Text(stringResource(R.string.dont_wait)) }
} }
} }
} }

View File

@@ -39,8 +39,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.Routes import eu.geyskens.pdfscan.Routes
import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.data.Page import eu.geyskens.pdfscan.data.Page
@@ -53,12 +55,12 @@ fun PagesScreen(vm: ScanViewModel, navController: NavController) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Pages (${pages.size})") }, title = { Text(stringResource(R.string.pages_title, pages.size)) },
actions = { actions = {
IconButton( IconButton(
onClick = { if (pages.isNotEmpty()) navController.navigate(Routes.EXPORT) }, 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( ExtendedFloatingActionButton(
onClick = { navController.navigate(Routes.CAMERA) }, onClick = { navController.navigate(Routes.CAMERA) },
icon = { Icon(Icons.Filled.Add, contentDescription = null) }, icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add page") }, text = { Text(stringResource(R.string.add_page)) },
) )
}, },
) { padding -> ) { padding ->
@@ -122,24 +124,24 @@ private fun PageRow(
bitmap?.let { bitmap?.let {
Image( Image(
bitmap = it.asImageBitmap(), bitmap = it.asImageBitmap(),
contentDescription = "Page ${index + 1}", contentDescription = stringResource(R.string.page_number, index + 1),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
} }
Spacer(Modifier.width(12.dp)) 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 { Column {
IconButton(onClick = onUp, enabled = !isFirst) { 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) { 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) { IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = "Delete", tint = Color(0xFFD32F2F)) Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete), tint = Color(0xFFD32F2F))
} }
} }
} }

View File

@@ -35,9 +35,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.data.ScanFilter import eu.geyskens.pdfscan.data.ScanFilter
import eu.geyskens.pdfscan.paperless.PaperlessClient import eu.geyskens.pdfscan.paperless.PaperlessClient
@@ -47,6 +50,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun SettingsScreen(vm: ScanViewModel, navController: NavController) { fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current
val settings by vm.settings.collectAsState() val settings by vm.settings.collectAsState()
var url by remember { mutableStateOf(settings.paperlessUrl) } var url by remember { mutableStateOf(settings.paperlessUrl) }
@@ -59,10 +63,10 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Settings") }, title = { Text(stringResource(R.string.settings)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) { 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()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp), 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( OutlinedTextField(
value = url, value = url,
onValueChange = { url = it; testResult = null }, 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, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
value = token, value = token,
onValueChange = { token = it; testResult = null }, onValueChange = { token = it; testResult = null },
label = { Text("API token") }, label = { Text(stringResource(R.string.api_token_label)) },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (url.startsWith("http://")) { if (url.startsWith("http://")) {
Text( Text(
"⚠ You're using http:// — your token and documents travel unencrypted over the " + stringResource(R.string.http_warning),
"network. Use https:// when you can.",
color = Color(0xFFB26A00), color = Color(0xFFB26A00),
style = androidx.compose.material3.MaterialTheme.typography.bodySmall, style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
) )
@@ -109,55 +112,55 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
testResult = null testResult = null
scope.launch { scope.launch {
testResult = when (val r = vm.testPaperless(url, token)) { 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 is PaperlessClient.Result.Failure -> r.message
else -> "Unexpected response from the server." else -> context.getString(R.string.unexpected_response)
} }
testing = false testing = false
} }
}, },
enabled = !testing, enabled = !testing,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Test connection") } ) { Text(stringResource(R.string.test_connection)) }
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
vm.savePaperless(url, token) vm.savePaperless(url, token)
snackbarHostState.showSnackbar("Settings saved") snackbarHostState.showSnackbar(context.getString(R.string.settings_saved))
} }
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Save") } ) { Text(stringResource(R.string.save)) }
} }
if (testing) { if (testing) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.width(20.dp)) CircularProgressIndicator(Modifier.width(20.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Testing") Text(stringResource(R.string.testing))
} }
} }
testResult?.let { Text(it, color = Color(0xFF1F6FEB)) } testResult?.let { Text(it, color = Color(0xFF1F6FEB)) }
Text( Text(
"Default filter", stringResource(R.string.default_filter),
style = androidx.compose.material3.MaterialTheme.typography.titleMedium, style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip( FilterChip(
selected = settings.defaultFilter == ScanFilter.COLOR, selected = settings.defaultFilter == ScanFilter.COLOR,
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.COLOR) } }, onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.COLOR) } },
label = { Text("Color") }, label = { Text(stringResource(R.string.filter_color)) },
) )
FilterChip( FilterChip(
selected = settings.defaultFilter == ScanFilter.GRAYSCALE, selected = settings.defaultFilter == ScanFilter.GRAYSCALE,
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.GRAYSCALE) } }, onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.GRAYSCALE) } },
label = { Text("Grayscale") }, label = { Text(stringResource(R.string.filter_grayscale)) },
) )
FilterChip( FilterChip(
selected = settings.defaultFilter == ScanFilter.BLACK_WHITE, selected = settings.defaultFilter == ScanFilter.BLACK_WHITE,
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.BLACK_WHITE) } }, onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.BLACK_WHITE) } },
label = { Text("Black & white") }, label = { Text(stringResource(R.string.filter_bw)) },
) )
} }
} }

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Camera -->
<string name="camera_permission_rationale">PaperScan heeft cameratoegang nodig om documenten te scannen. Er wordt niets naar de cloud gestuurd.</string>
<string name="grant_access">Toegang geven</string>
<string name="settings">Instellingen</string>
<string name="done">Klaar</string>
<!-- Pages -->
<string name="pages_title">Pagina\'s (%1$d)</string>
<string name="add_page">Pagina toevoegen</string>
<string name="export_pdf">Exporteer PDF</string>
<string name="page_number">Pagina %1$d</string>
<string name="move_up">Omhoog</string>
<string name="move_down">Omlaag</string>
<string name="delete">Verwijderen</string>
<!-- Crop -->
<string name="filter_color">Kleur</string>
<string name="filter_grayscale">Grijs</string>
<string name="filter_bw">Zwart-wit</string>
<string name="rotate">Draaien</string>
<string name="reset">Herstel</string>
<string name="apply">Toepassen</string>
<!-- Export -->
<string name="export_title">Exporteren (%1$d pagina\'s)</string>
<string name="file_name">Bestandsnaam</string>
<string name="page_size_a4">A4-pagina</string>
<string name="page_size_original">Originele grootte</string>
<string name="share_save">Delen / opslaan</string>
<string name="share_pdf">PDF delen</string>
<string name="upload_to_paperless">Upload naar Paperless</string>
<string name="set_up_paperless">Paperless instellen</string>
<string name="paperless_not_configured">Configureer eerst Paperless (server-URL + token) in de instellingen om te kunnen uploaden.</string>
<string name="uploading">Uploaden…</string>
<string name="working">Bezig…</string>
<string name="dont_wait">Niet wachten</string>
<string name="export_failed">Fout bij exporteren: %1$s</string>
<string name="error_generic">Fout: %1$s</string>
<string name="upload_added">Toegevoegd aan Paperless ✓</string>
<string name="upload_duplicate">Al aanwezig in Paperless (duplicaat) — niet opnieuw toegevoegd.</string>
<string name="upload_pending">Geüpload — Paperless is het nog aan het verwerken…</string>
<string name="upload_background">Geüpload — Paperless verwerkt het verder op de achtergrond.</string>
<!-- Settings -->
<string name="back">Terug</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">Server-URL (bv. https://paperless.thuis.lan)</string>
<string name="api_token_label">API-token</string>
<string name="http_warning">⚠ Je gebruikt http:// — je token en documenten gaan onversleuteld over het netwerk. Gebruik https:// als het even kan.</string>
<string name="test_connection">Verbinding testen</string>
<string name="save">Opslaan</string>
<string name="testing">Testen…</string>
<string name="settings_saved">Instellingen opgeslagen ✓</string>
<string name="connection_ok">Verbinding OK ✓</string>
<string name="unexpected_response">Onbekend antwoord van de server.</string>
<string name="default_filter">Standaardfilter</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Ongeldig token (%1$d).</string>
<string name="err_server_responded">Server antwoordde met %1$d.</string>
<string name="err_connection_failed">Verbinding mislukt.</string>
<string name="err_upload_failed_code">Upload mislukt: %1$d %2$s</string>
<string name="err_upload_failed">Upload mislukt.</string>
<string name="err_paperless_process">Paperless kon het document niet verwerken.</string>
<string name="err_paperless_not_setup">Paperless is nog niet ingesteld (URL + token).</string>
<string name="err_enter_url_token">Vul eerst URL en token in.</string>
</resources>

View File

@@ -1,4 +1,71 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">PaperScan</string> <string name="app_name" translatable="false">PaperScan</string>
<!-- Camera -->
<string name="camera_permission_rationale">PaperScan needs camera access to scan documents. Nothing is sent to the cloud.</string>
<string name="grant_access">Grant access</string>
<string name="settings">Settings</string>
<string name="done">Done</string>
<!-- Pages -->
<string name="pages_title">Pages (%1$d)</string>
<string name="add_page">Add page</string>
<string name="export_pdf">Export PDF</string>
<string name="page_number">Page %1$d</string>
<string name="move_up">Move up</string>
<string name="move_down">Move down</string>
<string name="delete">Delete</string>
<!-- Crop -->
<string name="filter_color">Color</string>
<string name="filter_grayscale">Grayscale</string>
<string name="filter_bw">Black &amp; white</string>
<string name="rotate">Rotate</string>
<string name="reset">Reset</string>
<string name="apply">Apply</string>
<!-- Export -->
<string name="export_title">Export (%1$d pages)</string>
<string name="file_name">File name</string>
<string name="page_size_a4">A4 page</string>
<string name="page_size_original">Original size</string>
<string name="share_save">Share / save</string>
<string name="share_pdf">Share PDF</string>
<string name="upload_to_paperless">Upload to Paperless</string>
<string name="set_up_paperless">Set up Paperless</string>
<string name="paperless_not_configured">Configure Paperless (server URL + token) in settings first to upload.</string>
<string name="uploading">Uploading…</string>
<string name="working">Working…</string>
<string name="dont_wait">Don\'t wait</string>
<string name="export_failed">Export failed: %1$s</string>
<string name="error_generic">Error: %1$s</string>
<string name="upload_added">Added to Paperless ✓</string>
<string name="upload_duplicate">Already in Paperless (duplicate) — not added again.</string>
<string name="upload_pending">Uploaded — Paperless is still processing…</string>
<string name="upload_background">Uploaded — Paperless will finish processing in the background.</string>
<!-- Settings -->
<string name="back">Back</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">Server URL (e.g. https://paperless.home.lan)</string>
<string name="api_token_label">API token</string>
<string name="http_warning">⚠ You\'re using http:// — your token and documents travel unencrypted over the network. Use https:// when you can.</string>
<string name="test_connection">Test connection</string>
<string name="save">Save</string>
<string name="testing">Testing…</string>
<string name="settings_saved">Settings saved ✓</string>
<string name="connection_ok">Connection OK ✓</string>
<string name="unexpected_response">Unexpected response from the server.</string>
<string name="default_filter">Default filter</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Invalid token (%1$d).</string>
<string name="err_server_responded">Server responded with %1$d.</string>
<string name="err_connection_failed">Connection failed.</string>
<string name="err_upload_failed_code">Upload failed: %1$d %2$s</string>
<string name="err_upload_failed">Upload failed.</string>
<string name="err_paperless_process">Paperless could not process the document.</string>
<string name="err_paperless_not_setup">Paperless isn\'t set up yet (URL + token).</string>
<string name="err_enter_url_token">Enter a URL and token first.</string>
</resources> </resources>