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:
@@ -1,6 +1,6 @@
|
||||
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:
|
||||
push:
|
||||
tags:
|
||||
@@ -9,26 +9,26 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Dedicated x86_64 runner (LXC op Proxmox) — de globale ARM64-runners hebben deze
|
||||
# label niet, dus zij pikken deze job niet op.
|
||||
# Dedicated x86_64 runner (LXC on Proxmox) — the global ARM64 runners don't have
|
||||
# this label, so they won't pick up this job.
|
||||
runs-on: paperscan
|
||||
# Laat het automatische Actions-token een release aanmaken + assets uploaden.
|
||||
# Let the automatic Actions token create a release + upload assets.
|
||||
permissions:
|
||||
contents: write
|
||||
# Image met Android SDK + JDK 17 + Gradle voorgeïnstalleerd.
|
||||
# Image with Android SDK + JDK 17 + Gradle preinstalled.
|
||||
container:
|
||||
image: mingc/android-build-box:latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Herstel signing keystore uit secret
|
||||
- name: Restore signing keystore from secret
|
||||
run: |
|
||||
echo "$SIGNING_KEYSTORE_BASE64" | base64 -d > "$GITHUB_WORKSPACE/release.jks"
|
||||
env:
|
||||
SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }}
|
||||
|
||||
- name: Genereer Gradle wrapper indien nodig
|
||||
- name: Generate Gradle wrapper if needed
|
||||
run: |
|
||||
if [ ! -f gradle/wrapper/gradle-wrapper.jar ]; then
|
||||
gradle wrapper --gradle-version 8.11.1
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
|
||||
|
||||
- name: Publiceer release met APK als download
|
||||
- name: Publish release with APK as a download
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
@@ -54,22 +54,22 @@ jobs:
|
||||
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
|
||||
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)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
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)
|
||||
fi
|
||||
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"
|
||||
OLD=$(curl -sS -H "$AUTH" "$API/releases/$REL_ID/assets" \
|
||||
| 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
|
||||
|
||||
# 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" \
|
||||
-H "$AUTH" -H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"$APK" | grep -oP '"browser_download_url":\s*"\K[^"]+' || true
|
||||
|
||||
123
README.md
123
README.md
@@ -1,110 +1,117 @@
|
||||
# PaperScan
|
||||
|
||||
Een lokale, privacy-bewuste documentscanner voor Android. Scant papieren documenten
|
||||
met de camera, detecteert automatisch de randen, corrigeert het perspectief en bundelt
|
||||
meerdere pagina's in één PDF. Die PDF kun je delen via Android of rechtstreeks uploaden
|
||||
naar je zelf-gehoste **Paperless-ngx**.
|
||||
A local, privacy-conscious document scanner for Android. Scan paper documents with your
|
||||
camera, automatically detect the edges, correct the perspective, and bundle multiple pages
|
||||
into a single PDF. That PDF can be shared through Android or uploaded directly to your
|
||||
self-hosted **Paperless-ngx**.
|
||||
|
||||
**Alles gebeurt op het toestel.** Camerabeelden, randdetectie (OpenCV) en PDF-creatie
|
||||
verlaten je telefoon niet. De enige netwerkverbinding is de optionele upload naar de
|
||||
Paperless-server die jij zelf configureert. Er is geen AI of cloud-verwerking.
|
||||
**Everything happens on the device.** Camera frames, edge detection (OpenCV) and PDF
|
||||
creation never leave your phone. The only network connection is the optional upload to the
|
||||
Paperless server you configure yourself. There is no AI or cloud processing.
|
||||
|
||||
## Functies
|
||||
## Features
|
||||
|
||||
- 📷 Camerapreview met multi-page vastleggen (pagina na pagina toevoegen)
|
||||
- ✂️ Automatische randdetectie + perspectiefcorrectie (OpenCV), met handmatige hoekcorrectie
|
||||
- 🎨 Filters per pagina: kleur, grijstinten, zwart-wit (documentmodus)
|
||||
- 🔄 Pagina's draaien, herordenen en verwijderen
|
||||
- 📄 Export naar multi-page PDF (A4 of originele grootte)
|
||||
- ☁️ Directe upload naar Paperless-ngx **of** delen via het Android-deelmenu
|
||||
- 🔐 API-token versleuteld opgeslagen (Android Keystore / EncryptedSharedPreferences)
|
||||
- 📷 Camera preview with multi-page capture (add pages one by one)
|
||||
- ✂️ Automatic edge detection + perspective correction (OpenCV), with manual corner adjustment
|
||||
- 🎨 Per-page filters: color, grayscale, black & white (document mode)
|
||||
- 🔄 Rotate, reorder and delete pages
|
||||
- 📄 Export to a multi-page PDF (A4 or original size)
|
||||
- ☁️ Direct upload to Paperless-ngx **or** share via the Android share sheet
|
||||
- 🔊 Capture feedback: shutter sound (only when the ringer isn't silenced) + a brief flash
|
||||
- 🔐 API token stored encrypted (Android Keystore / EncryptedSharedPreferences)
|
||||
|
||||
## Tech
|
||||
|
||||
Kotlin · Jetpack Compose · CameraX · OpenCV (`org.opencv:opencv` via Maven) ·
|
||||
`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
|
||||
de Gradle wrapper (`gradlew` + `gradle-wrapper.jar`).
|
||||
2. Sluit een toestel aan of start een emulator en druk op **Run**.
|
||||
1. Open the project in **Android Studio** (Ladybug or newer). This automatically generates
|
||||
the Gradle wrapper (`gradlew` + `gradle-wrapper.jar`).
|
||||
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
|
||||
./gradlew assembleDebug # debug-APK, geen signing nodig
|
||||
./gradlew assembleRelease # release-APK, vereist signing (zie onder)
|
||||
./gradlew assembleDebug # debug APK, no signing required
|
||||
./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
|
||||
keytool -genkey -v -keystore release.jks -keyalg RSA -keysize 2048 \
|
||||
-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
|
||||
(zie `keystore.properties.example`):
|
||||
For local release builds, create a `keystore.properties` in the project root
|
||||
(see `keystore.properties.example`):
|
||||
|
||||
```properties
|
||||
storeFile=/pad/naar/release.jks
|
||||
storeFile=/path/to/release.jks
|
||||
storePassword=...
|
||||
keyAlias=paperscan
|
||||
keyPassword=...
|
||||
```
|
||||
|
||||
## Bouwen via Gitea Actions
|
||||
## Building via Gitea Actions
|
||||
|
||||
De workflow `.gitea/workflows/build.yml` bouwt een getekende release-APK bij elke
|
||||
versietag (`git tag v0.1.0 && git push --tags`) of via een handmatige run.
|
||||
The workflow `.gitea/workflows/build.yml` builds a signed release APK on every version tag
|
||||
(`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_STORE_PASSWORD` | wachtwoord van de keystore |
|
||||
| `SIGNING_KEY_ALIAS` | bv. `paperscan` |
|
||||
| `SIGNING_KEY_PASSWORD` | wachtwoord van de sleutel |
|
||||
| `SIGNING_KEYSTORE_BASE64` | `base64 -w0 release.jks` (the keystore as base64) |
|
||||
| `SIGNING_STORE_PASSWORD` | keystore password |
|
||||
| `SIGNING_KEY_ALIAS` | e.g. `paperscan` |
|
||||
| `SIGNING_KEY_PASSWORD` | key password |
|
||||
|
||||
De build gebruikt env-vars, dus er komt niets gevoeligs in de repo terecht. De APK
|
||||
komt als artifact `paperscan-release` beschikbaar bij de workflow-run.
|
||||
The build uses env vars, so nothing sensitive ends up in the repo.
|
||||
|
||||
> 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**
|
||||
(of via `/api/token/`).
|
||||
2. Open in PaperScan het tandwiel → vul **server-URL** (bv. `https://paperless.thuis.lan`)
|
||||
en het **token** in → **Verbinding testen** → **Opslaan**.
|
||||
1. Create an API token in Paperless: **Settings → profile → API token** (or via `/api/token/`).
|
||||
2. In PaperScan, open the gear icon → enter the **server URL** (e.g. `https://paperless.home.lan`)
|
||||
and the **token** → **Test connection** → **Save**.
|
||||
|
||||
Gebruik bij voorkeur **https**; bij `http://` waarschuwt de app dat token en documenten
|
||||
onversleuteld over het netwerk gaan.
|
||||
Prefer **https**; with `http://` the app warns that the token and documents travel over the
|
||||
network unencrypted.
|
||||
|
||||
## Projectstructuur
|
||||
## Project layout
|
||||
|
||||
```
|
||||
app/src/main/java/eu/geyskens/pdfscan/
|
||||
├── PaperScanApp.kt # Application; laadt OpenCV native libs
|
||||
├── MainActivity.kt # Compose host + navigatie
|
||||
├── ScanViewModel.kt # scan-sessie, rendering, export, upload
|
||||
├── data/ # Page-model, ScanFilter, SettingsRepository
|
||||
├── PaperScanApp.kt # Application; loads OpenCV native libs
|
||||
├── MainActivity.kt # Compose host + navigation
|
||||
├── ScanViewModel.kt # scan session, rendering, export, upload
|
||||
├── data/ # Page model, ScanFilter, SettingsRepository
|
||||
├── scan/ # EdgeDetector, ImageProcessor, BitmapIo (OpenCV)
|
||||
├── pdf/ # PdfBuilder (multi-page PDF)
|
||||
├── paperless/ # PaperlessClient (OkHttp REST)
|
||||
├── paperless/ # PaperlessClient (OkHttp REST + task polling)
|
||||
└── ui/screens/ # Camera, Pages, Crop, Export, Settings
|
||||
```
|
||||
|
||||
## Roadmap / ideeën
|
||||
## Roadmap
|
||||
|
||||
- Live randdetectie-overlay tijdens het richten (nu bij vastleggen)
|
||||
- OCR-tekstlaag (bv. Tesseract) volledig on-device
|
||||
- Batch-import van bestaande foto's uit de galerij
|
||||
Tracked as issues in the repository:
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -115,7 +115,7 @@ 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 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)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ class ScanViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
suspend fun testPaperless(url: String, token: String): PaperlessClient.Result {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -53,11 +53,11 @@ class PaperlessClient(
|
||||
client.newCall(request).execute().use { resp ->
|
||||
when {
|
||||
resp.isSuccessful -> Result.Success
|
||||
resp.code == 401 || resp.code == 403 -> Result.Failure("Ongeldig token (${resp.code}).")
|
||||
else -> Result.Failure("Server antwoordde met ${resp.code}.")
|
||||
resp.code == 401 || resp.code == 403 -> Result.Failure("Invalid token (${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) {
|
||||
runCatching {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
if (taskId == null || taskId.equals("OK", ignoreCase = true)) return@withContext Result.Pending
|
||||
@@ -119,7 +119,7 @@ class PaperlessClient(
|
||||
"FAILURE" -> {
|
||||
val msg = task.result.orEmpty()
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ object BitmapIo {
|
||||
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
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)
|
||||
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
|
||||
@@ -77,13 +77,13 @@ private fun PermissionRequest(onRequest: () -> Unit) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"PaperScan heeft cameratoegang nodig om documenten te scannen. " +
|
||||
"Er wordt niets naar de cloud gestuurd.",
|
||||
"PaperScan needs camera access to scan documents. " +
|
||||
"Nothing is sent to the cloud.",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
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) },
|
||||
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.
|
||||
@@ -204,7 +204,7 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = "Klaar",
|
||||
contentDescription = "Done",
|
||||
tint = if (pages.isEmpty()) Color.Gray else Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,9 +135,9 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
|
||||
Modifier.fillMaxWidth().padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterOption("Kleur", filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR }
|
||||
FilterOption("Grijs", filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE }
|
||||
FilterOption("Zwart-wit", filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE }
|
||||
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 }
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -145,7 +145,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = { vm.rotate(pageId) }, modifier = Modifier.weight(1f)) {
|
||||
Text("Draaien")
|
||||
Text("Rotate")
|
||||
}
|
||||
OutlinedButton(
|
||||
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) }
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Herstel") }
|
||||
) { Text("Reset") }
|
||||
Button(
|
||||
onClick = {
|
||||
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()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Toepassen") }
|
||||
) { Text("Apply") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
fun mode() = if (a4) PageSizeMode.A4 else PageSizeMode.ORIGINAL
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Exporteren (${pages.size} pagina's)") }) },
|
||||
topBar = { TopAppBar(title = { Text("Export (${pages.size} pages)") }) },
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
||||
@@ -79,7 +79,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
OutlinedTextField(
|
||||
value = fileName,
|
||||
onValueChange = { fileName = it },
|
||||
label = { Text("Bestandsnaam") },
|
||||
label = { Text("File name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -89,12 +89,12 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
selected = a4,
|
||||
onClick = { a4 = true },
|
||||
shape = SegmentedButtonDefaults.itemShape(0, 2),
|
||||
) { Text("A4-pagina") }
|
||||
) { Text("A4 page") }
|
||||
SegmentedButton(
|
||||
selected = !a4,
|
||||
onClick = { a4 = false },
|
||||
shape = SegmentedButtonDefaults.itemShape(1, 2),
|
||||
) { Text("Originele grootte") }
|
||||
) { Text("Original size") }
|
||||
}
|
||||
|
||||
Button(
|
||||
@@ -113,9 +113,9 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(share, "PDF delen"))
|
||||
context.startActivity(Intent.createChooser(share, "Share PDF"))
|
||||
} catch (e: Exception) {
|
||||
status = "Fout bij exporteren: ${e.message}"
|
||||
status = "Export failed: ${e.message}"
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
@@ -126,7 +126,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Delen / opslaan")
|
||||
Text("Share / save")
|
||||
}
|
||||
|
||||
Button(
|
||||
@@ -140,19 +140,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 = "Toegevoegd aan Paperless ✓"
|
||||
status = "Added to Paperless ✓"
|
||||
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 ->
|
||||
status = "Geüpload — Paperless is het nog aan het verwerken…"
|
||||
status = "Uploaded — Paperless is still processing…"
|
||||
is PaperlessClient.Result.Failure ->
|
||||
status = r.message
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// User tapped "Niet wachten": the document was already sent.
|
||||
status = "Geüpload — Paperless verwerkt het verder op de achtergrond."
|
||||
// User tapped "Don't wait": the document was already sent.
|
||||
status = "Uploaded — Paperless will finish processing in the background."
|
||||
} catch (e: Exception) {
|
||||
status = "Fout: ${e.message}"
|
||||
status = "Error: ${e.message}"
|
||||
} finally {
|
||||
busy = false
|
||||
uploading = false
|
||||
@@ -169,24 +169,24 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
|
||||
if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) {
|
||||
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,
|
||||
color = Color(0xFF9A9A9A),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { navController.navigate(Routes.SETTINGS) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Paperless instellen") }
|
||||
) { Text("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) "Uploaden…" else "Bezig…")
|
||||
Text(if (uploading) "Uploading…" else "Working…")
|
||||
if (uploading) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
OutlinedButton(onClick = { uploadJob?.cancel() }) { Text("Niet wachten") }
|
||||
OutlinedButton(onClick = { uploadJob?.cancel() }) { Text("Don't wait") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,12 @@ fun PagesScreen(vm: ScanViewModel, navController: NavController) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Pagina's (${pages.size})") },
|
||||
title = { Text("Pages (${pages.size})") },
|
||||
actions = {
|
||||
IconButton(
|
||||
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(
|
||||
onClick = { navController.navigate(Routes.CAMERA) },
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text("Pagina toevoegen") },
|
||||
text = { Text("Add page") },
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
@@ -122,24 +122,24 @@ private fun PageRow(
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "Pagina ${index + 1}",
|
||||
contentDescription = "Page ${index + 1}",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
IconButton(onClick = onUp, enabled = !isFirst) {
|
||||
Icon(Icons.Filled.ArrowUpward, contentDescription = "Omhoog")
|
||||
Icon(Icons.Filled.ArrowUpward, contentDescription = "Move up")
|
||||
}
|
||||
IconButton(onClick = onDown, enabled = !isLast) {
|
||||
Icon(Icons.Filled.ArrowDownward, contentDescription = "Omlaag")
|
||||
Icon(Icons.Filled.ArrowDownward, contentDescription = "Move down")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = "Verwijderen", tint = Color(0xFFD32F2F))
|
||||
Icon(Icons.Filled.Delete, contentDescription = "Delete", tint = Color(0xFFD32F2F))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,10 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Instellingen") },
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
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(
|
||||
value = url,
|
||||
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,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it; testResult = null },
|
||||
label = { Text("API-token") },
|
||||
label = { Text("API token") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (url.startsWith("http://")) {
|
||||
Text(
|
||||
"⚠ Je gebruikt http:// — je token en documenten gaan onversleuteld over het " +
|
||||
"netwerk. Gebruik https:// als het even kan.",
|
||||
"⚠ You're using http:// — your token and documents travel unencrypted over the " +
|
||||
"network. Use https:// when you can.",
|
||||
color = Color(0xFFB26A00),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
@@ -109,55 +109,55 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
|
||||
testResult = null
|
||||
scope.launch {
|
||||
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
|
||||
else -> "Onbekend antwoord van de server."
|
||||
else -> "Unexpected response from the server."
|
||||
}
|
||||
testing = false
|
||||
}
|
||||
},
|
||||
enabled = !testing,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Verbinding testen") }
|
||||
) { Text("Test connection") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
vm.savePaperless(url, token)
|
||||
snackbarHostState.showSnackbar("Instellingen opgeslagen ✓")
|
||||
snackbarHostState.showSnackbar("Settings saved ✓")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Opslaan") }
|
||||
) { Text("Save") }
|
||||
}
|
||||
if (testing) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.width(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Testen…")
|
||||
Text("Testing…")
|
||||
}
|
||||
}
|
||||
testResult?.let { Text(it, color = Color(0xFF1F6FEB)) }
|
||||
|
||||
Text(
|
||||
"Standaardfilter",
|
||||
"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("Kleur") },
|
||||
label = { Text("Color") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = settings.defaultFilter == ScanFilter.GRAYSCALE,
|
||||
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.GRAYSCALE) } },
|
||||
label = { Text("Grijs") },
|
||||
label = { Text("Grayscale") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = settings.defaultFilter == ScanFilter.BLACK_WHITE,
|
||||
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.BLACK_WHITE) } },
|
||||
label = { Text("Zwart-wit") },
|
||||
label = { Text("Black & white") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Kopieer naar keystore.properties (staat in .gitignore) voor lokale release-builds.
|
||||
storeFile=/absoluut/pad/naar/release.jks
|
||||
# Copy to keystore.properties (gitignored) for local release builds.
|
||||
storeFile=/absolute/path/to/release.jks
|
||||
storePassword=CHANGEME
|
||||
keyAlias=paperscan
|
||||
keyPassword=CHANGEME
|
||||
|
||||
Reference in New Issue
Block a user