Translate the entire repo to English (UI strings, README, CI, comments)

Prep for public distribution. All user-facing strings, the README, the Gitea
workflow step names/comments and the keystore example are now English.
Follow-up i18n (string resources + locale files) is tracked in #5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 18:52:01 +02:00
parent 953efaf168
commit 3c77e9ab3e
11 changed files with 140 additions and 133 deletions

View File

@@ -1,6 +1,6 @@
name: Build APK name: Build APK
# Bouwt een getekende release-APK op elke versietag (v0.1.0, …) en op handmatige trigger. # Builds a signed release APK on every version tag (v0.1.0, …) and on manual trigger.
on: on:
push: push:
tags: tags:
@@ -9,26 +9,26 @@ on:
jobs: jobs:
build: build:
# Dedicated x86_64 runner (LXC op Proxmox) — de globale ARM64-runners hebben deze # Dedicated x86_64 runner (LXC on Proxmox) — the global ARM64 runners don't have
# label niet, dus zij pikken deze job niet op. # this label, so they won't pick up this job.
runs-on: paperscan runs-on: paperscan
# Laat het automatische Actions-token een release aanmaken + assets uploaden. # Let the automatic Actions token create a release + upload assets.
permissions: permissions:
contents: write contents: write
# Image met Android SDK + JDK 17 + Gradle voorgeïnstalleerd. # Image with Android SDK + JDK 17 + Gradle preinstalled.
container: container:
image: mingc/android-build-box:latest image: mingc/android-build-box:latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Herstel signing keystore uit secret - name: Restore signing keystore from secret
run: | run: |
echo "$SIGNING_KEYSTORE_BASE64" | base64 -d > "$GITHUB_WORKSPACE/release.jks" echo "$SIGNING_KEYSTORE_BASE64" | base64 -d > "$GITHUB_WORKSPACE/release.jks"
env: env:
SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }} SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }}
- name: Genereer Gradle wrapper indien nodig - name: Generate Gradle wrapper if needed
run: | run: |
if [ ! -f gradle/wrapper/gradle-wrapper.jar ]; then if [ ! -f gradle/wrapper/gradle-wrapper.jar ]; then
gradle wrapper --gradle-version 8.11.1 gradle wrapper --gradle-version 8.11.1
@@ -44,7 +44,7 @@ jobs:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
- name: Publiceer release met APK als download - name: Publish release with APK as a download
env: env:
TOKEN: ${{ github.token }} TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
@@ -54,22 +54,22 @@ jobs:
APK=$(ls app/build/outputs/apk/release/*.apk | head -1) APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
AUTH="Authorization: token $TOKEN" AUTH="Authorization: token $TOKEN"
# Hergebruik een bestaande release voor deze tag, of maak 'm aan. # Reuse an existing release for this tag, or create it.
REL_ID=$(curl -sS -H "$AUTH" "$API/releases/tags/$TAG" | grep -oP '"id":\s*\K[0-9]+' | head -1 || true) REL_ID=$(curl -sS -H "$AUTH" "$API/releases/tags/$TAG" | grep -oP '"id":\s*\K[0-9]+' | head -1 || true)
if [ -z "$REL_ID" ]; then if [ -z "$REL_ID" ]; then
REL_ID=$(curl -sS -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \ REL_ID=$(curl -sS -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"PaperScan $TAG\",\"body\":\"Automatische build van $TAG.\"}" \ -d "{\"tag_name\":\"$TAG\",\"name\":\"PaperScan $TAG\",\"body\":\"Automated build of $TAG.\"}" \
| grep -oP '"id":\s*\K[0-9]+' | head -1) | grep -oP '"id":\s*\K[0-9]+' | head -1)
fi fi
echo "Release id: $REL_ID" echo "Release id: $REL_ID"
# Verwijder een eventueel bestaande asset met dezelfde naam (bij re-run/hertag). # Remove any existing asset with the same name (on re-run/re-tag).
ASSET_NAME="paperscan-$TAG.apk" ASSET_NAME="paperscan-$TAG.apk"
OLD=$(curl -sS -H "$AUTH" "$API/releases/$REL_ID/assets" \ OLD=$(curl -sS -H "$AUTH" "$API/releases/$REL_ID/assets" \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"$ASSET_NAME\")" | head -1 || true) | grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"$ASSET_NAME\")" | head -1 || true)
if [ -n "$OLD" ]; then curl -sS -X DELETE -H "$AUTH" "$API/releases/$REL_ID/assets/$OLD"; fi if [ -n "$OLD" ]; then curl -sS -X DELETE -H "$AUTH" "$API/releases/$REL_ID/assets/$OLD"; fi
# Upload de getekende APK als download-asset. # Upload the signed APK as a downloadable asset.
curl -sS -X POST "$API/releases/$REL_ID/assets?name=$ASSET_NAME" \ curl -sS -X POST "$API/releases/$REL_ID/assets?name=$ASSET_NAME" \
-H "$AUTH" -H "Content-Type: application/octet-stream" \ -H "$AUTH" -H "Content-Type: application/octet-stream" \
--data-binary @"$APK" | grep -oP '"browser_download_url":\s*"\K[^"]+' || true --data-binary @"$APK" | grep -oP '"browser_download_url":\s*"\K[^"]+' || true

123
README.md
View File

@@ -1,110 +1,117 @@
# PaperScan # PaperScan
Een lokale, privacy-bewuste documentscanner voor Android. Scant papieren documenten A local, privacy-conscious document scanner for Android. Scan paper documents with your
met de camera, detecteert automatisch de randen, corrigeert het perspectief en bundelt camera, automatically detect the edges, correct the perspective, and bundle multiple pages
meerdere pagina's in één PDF. Die PDF kun je delen via Android of rechtstreeks uploaden into a single PDF. That PDF can be shared through Android or uploaded directly to your
naar je zelf-gehoste **Paperless-ngx**. self-hosted **Paperless-ngx**.
**Alles gebeurt op het toestel.** Camerabeelden, randdetectie (OpenCV) en PDF-creatie **Everything happens on the device.** Camera frames, edge detection (OpenCV) and PDF
verlaten je telefoon niet. De enige netwerkverbinding is de optionele upload naar de creation never leave your phone. The only network connection is the optional upload to the
Paperless-server die jij zelf configureert. Er is geen AI of cloud-verwerking. Paperless server you configure yourself. There is no AI or cloud processing.
## Functies ## Features
- 📷 Camerapreview met multi-page vastleggen (pagina na pagina toevoegen) - 📷 Camera preview with multi-page capture (add pages one by one)
- ✂️ Automatische randdetectie + perspectiefcorrectie (OpenCV), met handmatige hoekcorrectie - ✂️ Automatic edge detection + perspective correction (OpenCV), with manual corner adjustment
- 🎨 Filters per pagina: kleur, grijstinten, zwart-wit (documentmodus) - 🎨 Per-page filters: color, grayscale, black & white (document mode)
- 🔄 Pagina's draaien, herordenen en verwijderen - 🔄 Rotate, reorder and delete pages
- 📄 Export naar multi-page PDF (A4 of originele grootte) - 📄 Export to a multi-page PDF (A4 or original size)
- ☁️ Directe upload naar Paperless-ngx **of** delen via het Android-deelmenu - ☁️ Direct upload to Paperless-ngx **or** share via the Android share sheet
- 🔐 API-token versleuteld opgeslagen (Android Keystore / EncryptedSharedPreferences) - 🔊 Capture feedback: shutter sound (only when the ringer isn't silenced) + a brief flash
- 🔐 API token stored encrypted (Android Keystore / EncryptedSharedPreferences)
## Tech ## Tech
Kotlin · Jetpack Compose · CameraX · OpenCV (`org.opencv:opencv` via Maven) · Kotlin · Jetpack Compose · CameraX · OpenCV (`org.opencv:opencv` via Maven) ·
`PdfDocument` · OkHttp · DataStore. minSdk 26 (Android 8), target/compile SDK 35. `PdfDocument` · OkHttp · DataStore. minSdk 26 (Android 8), target/compile SDK 35.
## Lokaal bouwen ## Building locally
1. Open het project in **Android Studio** (Ladybug of nieuwer). Dit genereert automatisch 1. Open the project in **Android Studio** (Ladybug or newer). This automatically generates
de Gradle wrapper (`gradlew` + `gradle-wrapper.jar`). the Gradle wrapper (`gradlew` + `gradle-wrapper.jar`).
2. Sluit een toestel aan of start een emulator en druk op **Run**. 2. Connect a device or start an emulator and press **Run**.
Of vanaf de command line (na `gradle wrapper` één keer): Or from the command line (after `gradle wrapper` once):
```bash ```bash
./gradlew assembleDebug # debug-APK, geen signing nodig ./gradlew assembleDebug # debug APK, no signing required
./gradlew assembleRelease # release-APK, vereist signing (zie onder) ./gradlew assembleRelease # release APK, requires signing (see below)
``` ```
De release-APK verschijnt in `app/build/outputs/apk/release/`. The release APK ends up in `app/build/outputs/apk/release/`.
## Signing key aanmaken ## Creating a signing key
Nodig voor release-builds (en dus voor updates die elkaar kunnen overschrijven). Required for release builds (and therefore for updates that can overwrite each other).
```bash ```bash
keytool -genkey -v -keystore release.jks -keyalg RSA -keysize 2048 \ keytool -genkey -v -keystore release.jks -keyalg RSA -keysize 2048 \
-validity 10000 -alias paperscan -validity 10000 -alias paperscan
``` ```
Bewaar `release.jks` **veilig en buiten git** (staat al in `.gitignore`). Keep `release.jks` **safe and out of git** (already in `.gitignore`).
Voor lokale release-builds maak je een `keystore.properties` in de projectroot For local release builds, create a `keystore.properties` in the project root
(zie `keystore.properties.example`): (see `keystore.properties.example`):
```properties ```properties
storeFile=/pad/naar/release.jks storeFile=/path/to/release.jks
storePassword=... storePassword=...
keyAlias=paperscan keyAlias=paperscan
keyPassword=... keyPassword=...
``` ```
## Bouwen via Gitea Actions ## Building via Gitea Actions
De workflow `.gitea/workflows/build.yml` bouwt een getekende release-APK bij elke The workflow `.gitea/workflows/build.yml` builds a signed release APK on every version tag
versietag (`git tag v0.1.0 && git push --tags`) of via een handmatige run. (`git tag v0.1.0 && git push --tags`) or via a manual run, and publishes it as a **Release**
with the APK attached as a downloadable asset.
Zet in je Gitea-repo onder **Settings → Actions → Secrets** deze secrets klaar: Add these secrets under **Settings → Actions → Secrets** in your Gitea repo:
| Secret | Inhoud | | Secret | Contents |
| --- | --- | | --- | --- |
| `SIGNING_KEYSTORE_BASE64` | `base64 -w0 release.jks` (de keystore als base64) | | `SIGNING_KEYSTORE_BASE64` | `base64 -w0 release.jks` (the keystore as base64) |
| `SIGNING_STORE_PASSWORD` | wachtwoord van de keystore | | `SIGNING_STORE_PASSWORD` | keystore password |
| `SIGNING_KEY_ALIAS` | bv. `paperscan` | | `SIGNING_KEY_ALIAS` | e.g. `paperscan` |
| `SIGNING_KEY_PASSWORD` | wachtwoord van de sleutel | | `SIGNING_KEY_PASSWORD` | key password |
De build gebruikt env-vars, dus er komt niets gevoeligs in de repo terecht. De APK The build uses env vars, so nothing sensitive ends up in the repo.
komt als artifact `paperscan-release` beschikbaar bij de workflow-run.
> Zorg dat je Gitea Actions-runner Docker-images kan trekken (`mingc/android-build-box`). > The build runs in a container (`mingc/android-build-box`), so the Actions runner must be
> **x86_64** — the Android build tools (aapt2) are x86_64-Linux only and won't run natively
> on an ARM64 runner.
## Paperless-ngx instellen (in de app) ## Setting up Paperless-ngx (in the app)
1. Maak in Paperless een API-token aan: **Instellingen → profiel → API-token** 1. Create an API token in Paperless: **Settings → profile → API token** (or via `/api/token/`).
(of via `/api/token/`). 2. In PaperScan, open the gear icon → enter the **server URL** (e.g. `https://paperless.home.lan`)
2. Open in PaperScan het tandwiel → vul **server-URL** (bv. `https://paperless.thuis.lan`) and the **token****Test connection****Save**.
en het **token** in → **Verbinding testen****Opslaan**.
Gebruik bij voorkeur **https**; bij `http://` waarschuwt de app dat token en documenten Prefer **https**; with `http://` the app warns that the token and documents travel over the
onversleuteld over het netwerk gaan. network unencrypted.
## Projectstructuur ## Project layout
``` ```
app/src/main/java/eu/geyskens/pdfscan/ app/src/main/java/eu/geyskens/pdfscan/
├── PaperScanApp.kt # Application; laadt OpenCV native libs ├── PaperScanApp.kt # Application; loads OpenCV native libs
├── MainActivity.kt # Compose host + navigatie ├── MainActivity.kt # Compose host + navigation
├── ScanViewModel.kt # scan-sessie, rendering, export, upload ├── ScanViewModel.kt # scan session, rendering, export, upload
├── data/ # Page-model, ScanFilter, SettingsRepository ├── data/ # Page model, ScanFilter, SettingsRepository
├── scan/ # EdgeDetector, ImageProcessor, BitmapIo (OpenCV) ├── scan/ # EdgeDetector, ImageProcessor, BitmapIo (OpenCV)
├── pdf/ # PdfBuilder (multi-page PDF) ├── pdf/ # PdfBuilder (multi-page PDF)
├── paperless/ # PaperlessClient (OkHttp REST) ├── paperless/ # PaperlessClient (OkHttp REST + task polling)
└── ui/screens/ # Camera, Pages, Crop, Export, Settings └── ui/screens/ # Camera, Pages, Crop, Export, Settings
``` ```
## Roadmap / ideeën ## Roadmap
- Live randdetectie-overlay tijdens het richten (nu bij vastleggen) Tracked as issues in the repository:
- OCR-tekstlaag (bv. Tesseract) volledig on-device
- Batch-import van bestaande foto's uit de galerij - Live edge-detection overlay while aiming (like Microsoft Lens)
- Internationalization: extract UI strings to resources + add translations
- Batch import scans from the gallery
> OCR is intentionally **not** done on-device: Paperless-ngx already runs OCR (via
> OCRmyPDF/Tesseract) when it consumes the uploaded document.

View File

@@ -115,7 +115,7 @@ 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 is nog niet ingesteld (URL + token).") return PaperlessClient.Result.Failure("Paperless isn't set up yet (URL + token).")
} }
return PaperlessClient(s.paperlessUrl, token).upload(pdf, title) return PaperlessClient(s.paperlessUrl, token).upload(pdf, title)
} }
@@ -133,7 +133,7 @@ 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("Vul eerst URL en token in.") return PaperlessClient.Result.Failure("Enter a URL and token first.")
} }
return PaperlessClient(url.trim(), token.trim()).testConnection() return PaperlessClient(url.trim(), token.trim()).testConnection()
} }

View File

@@ -53,11 +53,11 @@ 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("Ongeldig token (${resp.code}).") resp.code == 401 || resp.code == 403 -> Result.Failure("Invalid token (${resp.code}).")
else -> Result.Failure("Server antwoordde met ${resp.code}.") else -> Result.Failure("Server responded with ${resp.code}.")
} }
} }
}.getOrElse { Result.Failure(it.message ?: "Verbinding mislukt.") } }.getOrElse { Result.Failure(it.message ?: "Connection failed.") }
} }
/** /**
@@ -85,12 +85,12 @@ 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 mislukt: ${resp.code} ${resp.message}") if (!resp.isSuccessful) error("Upload failed: ${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 mislukt.") } val taskId = posted.getOrElse { return@withContext Result.Failure(it.message ?: "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 +119,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 kon het document niet verwerken." }) else Result.Failure(msg.ifBlank { "Paperless could not process the document." })
} }
// PENDING / STARTED / RETRY / unknown → keep waiting. // PENDING / STARTED / RETRY / unknown → keep waiting.
} }

View File

@@ -21,7 +21,7 @@ object BitmapIo {
val opts = BitmapFactory.Options().apply { inSampleSize = sample } val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = BitmapFactory.decodeFile(file.absolutePath, opts) val bitmap = BitmapFactory.decodeFile(file.absolutePath, opts)
?: error("Kon afbeelding niet decoderen: ${file.name}") ?: error("Could not decode image: ${file.name}")
val orientation = ExifInterface(file.absolutePath) val orientation = ExifInterface(file.absolutePath)
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)

View File

@@ -77,13 +77,13 @@ 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 heeft cameratoegang nodig om documenten te scannen. " + "PaperScan needs camera access to scan documents. " +
"Er wordt niets naar de cloud gestuurd.", "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("Toegang geven") } Button(onClick = onRequest) { Text("Grant access") }
} }
} }
} }
@@ -143,7 +143,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 = "Instellingen", tint = Color.White) Icon(Icons.Filled.Settings, contentDescription = "Settings", tint = Color.White)
} }
// Bottom controls. // Bottom controls.
@@ -204,7 +204,7 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
) { ) {
Icon( Icon(
Icons.Filled.Check, Icons.Filled.Check,
contentDescription = "Klaar", contentDescription = "Done",
tint = if (pages.isEmpty()) Color.Gray else Color.White, tint = if (pages.isEmpty()) Color.Gray else Color.White,
) )
} }

View File

@@ -135,9 +135,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("Kleur", filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR } FilterOption("Color", filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR }
FilterOption("Grijs", filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE } FilterOption("Grayscale", filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE }
FilterOption("Zwart-wit", filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE } FilterOption("Black & white", filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE }
} }
Row( Row(
@@ -145,7 +145,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("Draaien") Text("Rotate")
} }
OutlinedButton( OutlinedButton(
onClick = { onClick = {
@@ -153,7 +153,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("Herstel") } ) { Text("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 +161,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
navController.popBackStack() navController.popBackStack()
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Toepassen") } ) { Text("Apply") }
} }
} }
} }

View File

@@ -70,7 +70,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("Exporteren (${pages.size} pagina's)") }) }, topBar = { TopAppBar(title = { Text("Export (${pages.size} pages)") }) },
) { padding -> ) { padding ->
Column( Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp), Modifier.fillMaxSize().padding(padding).padding(16.dp),
@@ -79,7 +79,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
OutlinedTextField( OutlinedTextField(
value = fileName, value = fileName,
onValueChange = { fileName = it }, onValueChange = { fileName = it },
label = { Text("Bestandsnaam") }, label = { Text("File name") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -89,12 +89,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-pagina") } ) { Text("A4 page") }
SegmentedButton( SegmentedButton(
selected = !a4, selected = !a4,
onClick = { a4 = false }, onClick = { a4 = false },
shape = SegmentedButtonDefaults.itemShape(1, 2), shape = SegmentedButtonDefaults.itemShape(1, 2),
) { Text("Originele grootte") } ) { Text("Original size") }
} }
Button( Button(
@@ -113,9 +113,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, "PDF delen")) context.startActivity(Intent.createChooser(share, "Share PDF"))
} catch (e: Exception) { } catch (e: Exception) {
status = "Fout bij exporteren: ${e.message}" status = "Export failed: ${e.message}"
} finally { } finally {
busy = false busy = false
} }
@@ -126,7 +126,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("Delen / opslaan") Text("Share / save")
} }
Button( Button(
@@ -140,19 +140,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 = "Toegevoegd aan Paperless ✓" status = "Added to Paperless ✓"
is PaperlessClient.Result.Duplicate -> is PaperlessClient.Result.Duplicate ->
status = "Al aanwezig in Paperless (duplicaat) — niet opnieuw toegevoegd." status = "Already in Paperless (duplicate) — not added again."
is PaperlessClient.Result.Pending -> is PaperlessClient.Result.Pending ->
status = "Geüpload — Paperless is het nog aan het verwerken" status = "Uploaded — Paperless is still processing"
is PaperlessClient.Result.Failure -> is PaperlessClient.Result.Failure ->
status = r.message status = r.message
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
// User tapped "Niet wachten": the document was already sent. // User tapped "Don't wait": the document was already sent.
status = "Geüpload — Paperless verwerkt het verder op de achtergrond." status = "Uploaded — Paperless will finish processing in the background."
} catch (e: Exception) { } catch (e: Exception) {
status = "Fout: ${e.message}" status = "Error: ${e.message}"
} finally { } finally {
busy = false busy = false
uploading = false uploading = false
@@ -169,24 +169,24 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) { if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) {
Text( Text(
"Configureer eerst Paperless (server-URL + token) in de instellingen om te kunnen uploaden.", "Configure Paperless (server URL + token) in settings first to upload.",
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("Paperless instellen") } ) { Text("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) "Uploaden" else "Bezig…") Text(if (uploading) "Uploading" else "Working…")
if (uploading) { if (uploading) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
OutlinedButton(onClick = { uploadJob?.cancel() }) { Text("Niet wachten") } OutlinedButton(onClick = { uploadJob?.cancel() }) { Text("Don't wait") }
} }
} }
} }

View File

@@ -53,12 +53,12 @@ fun PagesScreen(vm: ScanViewModel, navController: NavController) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Pagina's (${pages.size})") }, title = { Text("Pages (${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 = "Exporteer PDF") Icon(Icons.Filled.PictureAsPdf, contentDescription = "Export PDF")
} }
}, },
) )
@@ -67,7 +67,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("Pagina toevoegen") }, text = { Text("Add page") },
) )
}, },
) { padding -> ) { padding ->
@@ -122,24 +122,24 @@ private fun PageRow(
bitmap?.let { bitmap?.let {
Image( Image(
bitmap = it.asImageBitmap(), bitmap = it.asImageBitmap(),
contentDescription = "Pagina ${index + 1}", contentDescription = "Page ${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("Pagina ${index + 1}", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) Text("Page ${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 = "Omhoog") Icon(Icons.Filled.ArrowUpward, contentDescription = "Move up")
} }
IconButton(onClick = onDown, enabled = !isLast) { IconButton(onClick = onDown, enabled = !isLast) {
Icon(Icons.Filled.ArrowDownward, contentDescription = "Omlaag") Icon(Icons.Filled.ArrowDownward, contentDescription = "Move down")
} }
} }
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = "Verwijderen", tint = Color(0xFFD32F2F)) Icon(Icons.Filled.Delete, contentDescription = "Delete", tint = Color(0xFFD32F2F))
} }
} }
} }

View File

@@ -59,10 +59,10 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Instellingen") }, title = { Text("Settings") },
navigationIcon = { navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Terug") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
) )
@@ -81,22 +81,22 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
OutlinedTextField( OutlinedTextField(
value = url, value = url,
onValueChange = { url = it; testResult = null }, onValueChange = { url = it; testResult = null },
label = { Text("Server-URL (bv. https://paperless.thuis.lan)") }, label = { Text("Server URL (e.g. https://paperless.home.lan)") },
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("API token") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (url.startsWith("http://")) { if (url.startsWith("http://")) {
Text( Text(
"Je gebruikt http:// — je token en documenten gaan onversleuteld over het " + "You're using http:// — your token and documents travel unencrypted over the " +
"netwerk. Gebruik https:// als het even kan.", "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 +109,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 -> "Verbinding OK ✓" is PaperlessClient.Result.Success -> "Connection OK ✓"
is PaperlessClient.Result.Failure -> r.message is PaperlessClient.Result.Failure -> r.message
else -> "Onbekend antwoord van de server." else -> "Unexpected response from the server."
} }
testing = false testing = false
} }
}, },
enabled = !testing, enabled = !testing,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Verbinding testen") } ) { Text("Test connection") }
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
vm.savePaperless(url, token) vm.savePaperless(url, token)
snackbarHostState.showSnackbar("Instellingen opgeslagen") snackbarHostState.showSnackbar("Settings saved")
} }
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Opslaan") } ) { Text("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("Testen") Text("Testing")
} }
} }
testResult?.let { Text(it, color = Color(0xFF1F6FEB)) } testResult?.let { Text(it, color = Color(0xFF1F6FEB)) }
Text( Text(
"Standaardfilter", "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("Kleur") }, label = { Text("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("Grijs") }, label = { Text("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("Zwart-wit") }, label = { Text("Black & white") },
) )
} }
} }

View File

@@ -1,5 +1,5 @@
# Kopieer naar keystore.properties (staat in .gitignore) voor lokale release-builds. # Copy to keystore.properties (gitignored) for local release builds.
storeFile=/absoluut/pad/naar/release.jks storeFile=/absolute/path/to/release.jks
storePassword=CHANGEME storePassword=CHANGEME
keyAlias=paperscan keyAlias=paperscan
keyPassword=CHANGEME keyPassword=CHANGEME