4 Commits

Author SHA1 Message Date
sam
e02e69052d Add German, French, Spanish, Italian and Portuguese translations; v0.3.1
All checks were successful
Build APK / build (push) Successful in 6m9s
Seven UI languages now: English, Dutch, German, French, Spanish, Italian,
Portuguese. Each values-xx/strings.xml carries the full string set (verified
lint-clean). Bump to versionCode 6 / versionName 0.3.1.

Fixes #7

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:02:47 +02:00
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
sam
3a3f18153f Add live edge-detection overlay while aiming; v0.2.0
All checks were successful
Build APK / build (push) Successful in 6m9s
Bind a CameraX ImageAnalysis use case that runs EdgeDetector on downscaled
YUV frames (throttled ~6/s) and draw the detected document quad as a live
green overlay on the preview, mapped through the FILL_CENTER crop. Preview
switched to TextureView (COMPATIBLE) mode so the Compose overlay renders on
top. Bump versionCode 4 / versionName 0.2.0.

Fixes #4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:08:48 +02:00
sam
3c77e9ab3e 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>
2026-07-13 18:52:01 +02:00
25 changed files with 790 additions and 152 deletions

BIN
.claude/i18n.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
.claude/overlay1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
.claude/overlay2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
.claude/overlay3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

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

@@ -24,8 +24,8 @@ android {
applicationId = "eu.geyskens.pdfscan" applicationId = "eu.geyskens.pdfscan"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 3 versionCode = 6
versionName = "0.1.2" versionName = "0.3.1"
// 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 is nog niet ingesteld (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("Vul eerst URL en token in.") 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("Ongeldig token (${resp.code}).") resp.code == 401 || resp.code == 403 ->
else -> Result.Failure("Server antwoordde met ${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 ?: "Verbinding mislukt.") } }.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 mislukt: ${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 mislukt.") } 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 kon het document niet verwerken." }) 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

@@ -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

@@ -0,0 +1,65 @@
package eu.geyskens.pdfscan.scan
import android.graphics.PointF
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
/**
* Runs [EdgeDetector] on live camera frames (throttled) and reports the detected document
* corners, normalized to a display-oriented frame, together with that frame's aspect ratio
* (width / height) so the overlay can map them onto the preview.
*/
class DocumentAnalyzer(
private val onResult: (corners: List<PointF>?, frameAspect: Float) -> Unit,
) : ImageAnalysis.Analyzer {
@Volatile
private var lastRun = 0L
override fun analyze(image: ImageProxy) {
val now = System.currentTimeMillis()
if (now - lastRun < THROTTLE_MS) {
image.close()
return
}
lastRun = now
val result = try {
detect(image)
} catch (e: Throwable) {
android.util.Log.w("DocAnalyzer", "detect failed", e)
null
}
image.close()
if (result != null) onResult(result.first, result.second)
}
private fun detect(image: ImageProxy): Pair<List<PointF>?, Float> {
// The Y plane of the YUV frame is already a grayscale image.
val plane = image.planes[0]
val rowStride = plane.rowStride
val buffer = plane.buffer
val out = ByteArray(image.width * image.height)
for (row in 0 until image.height) {
buffer.position(row * rowStride)
buffer.get(out, row * image.width, image.width)
}
val gray = Mat(image.height, image.width, CvType.CV_8UC1)
gray.put(0, 0, out)
// Rotate the sensor-oriented frame to match how the preview is displayed.
when (image.imageInfo.rotationDegrees) {
90 -> Core.rotate(gray, gray, Core.ROTATE_90_CLOCKWISE)
180 -> Core.rotate(gray, gray, Core.ROTATE_180)
270 -> Core.rotate(gray, gray, Core.ROTATE_90_COUNTERCLOCKWISE)
}
val aspect = gray.cols().toFloat() / gray.rows()
return EdgeDetector.detectFromGray(gray) to aspect // detectFromGray releases gray
}
companion object {
private const val THROTTLE_MS = 150L
}
}

View File

@@ -22,17 +22,25 @@ object EdgeDetector {
fun detect(bitmap: Bitmap): List<PointF>? { fun detect(bitmap: Bitmap): List<PointF>? {
val src = Mat() val src = Mat()
Utils.bitmapToMat(bitmap, src) Utils.bitmapToMat(bitmap, src)
try {
val scale = PROCESS_WIDTH / src.cols()
val work = Mat()
Imgproc.resize(src, work, Size(PROCESS_WIDTH, src.rows() * scale))
val gray = Mat() val gray = Mat()
Imgproc.cvtColor(work, gray, Imgproc.COLOR_RGBA2GRAY) Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY)
Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0) src.release()
return detectFromGray(gray)
}
/**
* Detects on a full-resolution single-channel grayscale [gray] Mat (e.g. a camera
* frame's Y plane). Corners are normalized to [gray]'s dimensions. Releases [gray].
*/
fun detectFromGray(gray: Mat): List<PointF>? {
try {
val scale = PROCESS_WIDTH / gray.cols()
val work = Mat()
Imgproc.resize(gray, work, Size(PROCESS_WIDTH, gray.rows() * scale))
Imgproc.GaussianBlur(work, work, Size(5.0, 5.0), 0.0)
val edges = Mat() val edges = Mat()
Imgproc.Canny(gray, edges, 50.0, 150.0) Imgproc.Canny(work, edges, 50.0, 150.0)
// Close small gaps so contours form a continuous loop. // Close small gaps so contours form a continuous loop.
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0)) val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0))
Imgproc.dilate(edges, edges, kernel) Imgproc.dilate(edges, edges, kernel)
@@ -65,7 +73,7 @@ object EdgeDetector {
} }
return best return best
} finally { } finally {
src.release() gray.release()
} }
} }

View File

@@ -1,9 +1,13 @@
package eu.geyskens.pdfscan.ui.screens package eu.geyskens.pdfscan.ui.screens
import android.Manifest import android.Manifest
import android.graphics.PointF
import android.media.AudioManager import android.media.AudioManager
import android.media.MediaActionSound import android.media.MediaActionSound
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview import androidx.camera.core.Preview
@@ -11,6 +15,7 @@ import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -42,9 +47,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.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
@@ -52,8 +59,10 @@ 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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -77,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 heeft cameratoegang nodig om documenten te scannen. " + stringResource(R.string.camera_permission_rationale),
"Er wordt niets naar de cloud gestuurd.",
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(stringResource(R.string.grant_access)) }
} }
} }
} }
@@ -96,12 +104,27 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
val pages by vm.pages.collectAsState() val pages by vm.pages.collectAsState()
val executor = remember { Executors.newSingleThreadExecutor() } val executor = remember { Executors.newSingleThreadExecutor() }
// Same 4:3 aspect for preview and analysis so the live overlay lines up with the preview.
val resolutionSelector = remember {
ResolutionSelector.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
.build()
}
val imageCapture = remember { val imageCapture = remember {
ImageCapture.Builder() ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build() .build()
} }
val imageAnalysis = remember {
ImageAnalysis.Builder()
.setResolutionSelector(resolutionSelector)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
}
var capturing by remember { mutableStateOf(false) } var capturing by remember { mutableStateOf(false) }
// Live document corners (normalized, display-oriented) + the analysis frame aspect ratio.
var liveCorners by remember { mutableStateOf<List<PointF>?>(null) }
var frameAspect by remember { mutableStateOf(0f) }
// Capture feedback: system shutter sound + a brief white flash over the preview, // Capture feedback: system shutter sound + a brief white flash over the preview,
// so there's clear confirmation even when the phone is on silent. // so there's clear confirmation even when the phone is on silent.
@@ -120,15 +143,27 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
} }
val previewView = remember { val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER } PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
// TextureView mode so the Compose overlay draws on top of the preview
// (SurfaceView mode composites on a separate layer that hides the overlay).
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
} }
androidx.compose.runtime.LaunchedEffect(Unit) { androidx.compose.runtime.LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.getInstance(context).await() val provider = ProcessCameraProvider.getInstance(context).await()
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } val preview = Preview.Builder()
.setResolutionSelector(resolutionSelector)
.build()
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
imageAnalysis.setAnalyzer(executor, DocumentAnalyzer { corners, aspect ->
liveCorners = corners
frameAspect = aspect
})
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle( provider.bindToLifecycle(
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture, imageAnalysis
) )
} }
@@ -138,12 +173,33 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Live edge-detection overlay, mapped onto the FILL_CENTER-cropped preview.
Canvas(Modifier.fillMaxSize()) {
val corners = liveCorners
if (corners != null && corners.size == 4 && frameAspect > 0f) {
val viewAspect = size.width / size.height
val dispW: Float
val dispH: Float
if (frameAspect > viewAspect) {
dispH = size.height; dispW = size.height * frameAspect
} else {
dispW = size.width; dispH = size.width / frameAspect
}
val offX = (size.width - dispW) / 2f
val offY = (size.height - dispH) / 2f
val pts = corners.map { Offset(offX + it.x * dispW, offY + it.y * dispH) }
for (i in pts.indices) {
drawLine(Color(0xFF00E676), pts[i], pts[(i + 1) % 4], strokeWidth = 5f)
}
}
}
// Top bar: settings. // Top bar: settings.
IconButton( IconButton(
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 = stringResource(R.string.settings), tint = Color.White)
} }
// Bottom controls. // Bottom controls.
@@ -204,7 +260,7 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
) { ) {
Icon( Icon(
Icons.Filled.Check, Icons.Filled.Check,
contentDescription = "Klaar", 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("Kleur", filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR } FilterOption(stringResource(R.string.filter_color), filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR }
FilterOption("Grijs", filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE } FilterOption(stringResource(R.string.filter_grayscale), filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE }
FilterOption("Zwart-wit", 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("Draaien") 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("Herstel") } ) { 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("Toepassen") } ) { 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("Exporteren (${pages.size} pagina's)") }) }, 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("Bestandsnaam") }, 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-pagina") } ) { 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("Originele grootte") } ) { 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, "PDF delen")) context.startActivity(Intent.createChooser(share, context.getString(R.string.share_pdf)))
} catch (e: Exception) { } catch (e: Exception) {
status = "Fout bij exporteren: ${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("Delen / opslaan") 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 = "Toegevoegd aan Paperless ✓" status = context.getString(R.string.upload_added)
is PaperlessClient.Result.Duplicate -> is PaperlessClient.Result.Duplicate ->
status = "Al aanwezig in Paperless (duplicaat) — niet opnieuw toegevoegd." status = context.getString(R.string.upload_duplicate)
is PaperlessClient.Result.Pending -> is PaperlessClient.Result.Pending ->
status = "Geüpload — Paperless is het nog aan het verwerken…" 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 "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 = context.getString(R.string.upload_background)
} catch (e: Exception) { } catch (e: Exception) {
status = "Fout: ${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(
"Configureer eerst Paperless (server-URL + token) in de instellingen om te kunnen uploaden.", 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("Paperless instellen") } ) { 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) "Uploaden…" else "Bezig…") 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("Niet wachten") } 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("Pagina's (${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 = "Exporteer 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("Pagina toevoegen") }, 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 = "Pagina ${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("Pagina ${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 = "Omhoog") 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 = "Omlaag") Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down))
} }
} }
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = "Verwijderen", 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("Instellingen") }, title = { Text(stringResource(R.string.settings)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Terug") 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 (bv. https://paperless.thuis.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(
"⚠ Je gebruikt http:// — je token en documenten gaan onversleuteld over het " + stringResource(R.string.http_warning),
"netwerk. Gebruik https:// als het even kan.",
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 -> "Verbinding OK ✓" is PaperlessClient.Result.Success -> context.getString(R.string.connection_ok)
is PaperlessClient.Result.Failure -> r.message is PaperlessClient.Result.Failure -> r.message
else -> "Onbekend antwoord van de 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("Verbinding testen") } ) { Text(stringResource(R.string.test_connection)) }
Button( Button(
onClick = { onClick = {
scope.launch { scope.launch {
vm.savePaperless(url, token) vm.savePaperless(url, token)
snackbarHostState.showSnackbar("Instellingen opgeslagen ✓") snackbarHostState.showSnackbar(context.getString(R.string.settings_saved))
} }
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Opslaan") } ) { 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("Testen…") Text(stringResource(R.string.testing))
} }
} }
testResult?.let { Text(it, color = Color(0xFF1F6FEB)) } testResult?.let { Text(it, color = Color(0xFF1F6FEB)) }
Text( Text(
"Standaardfilter", 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("Kleur") }, 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("Grijs") }, 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("Zwart-wit") }, 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 benötigt Kamerazugriff, um Dokumente zu scannen. Es wird nichts in die Cloud gesendet.</string>
<string name="grant_access">Zugriff erlauben</string>
<string name="settings">Einstellungen</string>
<string name="done">Fertig</string>
<!-- Pages -->
<string name="pages_title">Seiten (%1$d)</string>
<string name="add_page">Seite hinzufügen</string>
<string name="export_pdf">PDF exportieren</string>
<string name="page_number">Seite %1$d</string>
<string name="move_up">Nach oben</string>
<string name="move_down">Nach unten</string>
<string name="delete">Löschen</string>
<!-- Crop -->
<string name="filter_color">Farbe</string>
<string name="filter_grayscale">Graustufen</string>
<string name="filter_bw">Schwarzweiß</string>
<string name="rotate">Drehen</string>
<string name="reset">Zurücksetzen</string>
<string name="apply">Anwenden</string>
<!-- Export -->
<string name="export_title">Export (%1$d Seiten)</string>
<string name="file_name">Dateiname</string>
<string name="page_size_a4">A4-Seite</string>
<string name="page_size_original">Originalgröße</string>
<string name="share_save">Teilen / speichern</string>
<string name="share_pdf">PDF teilen</string>
<string name="upload_to_paperless">Zu Paperless hochladen</string>
<string name="set_up_paperless">Paperless einrichten</string>
<string name="paperless_not_configured">Richte zuerst Paperless (Server-URL + Token) in den Einstellungen ein, um hochzuladen.</string>
<string name="uploading">Wird hochgeladen…</string>
<string name="working">Wird bearbeitet…</string>
<string name="dont_wait">Nicht warten</string>
<string name="export_failed">Export fehlgeschlagen: %1$s</string>
<string name="error_generic">Fehler: %1$s</string>
<string name="upload_added">Zu Paperless hinzugefügt ✓</string>
<string name="upload_duplicate">Bereits in Paperless (Duplikat) — nicht erneut hinzugefügt.</string>
<string name="upload_pending">Hochgeladen — Paperless verarbeitet es noch…</string>
<string name="upload_background">Hochgeladen — Paperless verarbeitet es im Hintergrund weiter.</string>
<!-- Settings -->
<string name="back">Zurück</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">Server-URL (z. B. https://paperless.home.lan)</string>
<string name="api_token_label">API-Token</string>
<string name="http_warning">⚠ Du verwendest http:// — dein Token und deine Dokumente werden unverschlüsselt übertragen. Verwende nach Möglichkeit https://.</string>
<string name="test_connection">Verbindung testen</string>
<string name="save">Speichern</string>
<string name="testing">Wird getestet…</string>
<string name="settings_saved">Einstellungen gespeichert ✓</string>
<string name="connection_ok">Verbindung OK ✓</string>
<string name="unexpected_response">Unerwartete Antwort vom Server.</string>
<string name="default_filter">Standardfilter</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Ungültiges Token (%1$d).</string>
<string name="err_server_responded">Server antwortete mit %1$d.</string>
<string name="err_connection_failed">Verbindung fehlgeschlagen.</string>
<string name="err_upload_failed_code">Upload fehlgeschlagen: %1$d %2$s</string>
<string name="err_upload_failed">Upload fehlgeschlagen.</string>
<string name="err_paperless_process">Paperless konnte das Dokument nicht verarbeiten.</string>
<string name="err_paperless_not_setup">Paperless ist noch nicht eingerichtet (URL + Token).</string>
<string name="err_enter_url_token">Gib zuerst eine URL und ein Token ein.</string>
</resources>

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Camera -->
<string name="camera_permission_rationale">PaperScan necesita acceso a la cámara para escanear documentos. No se envía nada a la nube.</string>
<string name="grant_access">Conceder acceso</string>
<string name="settings">Ajustes</string>
<string name="done">Listo</string>
<!-- Pages -->
<string name="pages_title">Páginas (%1$d)</string>
<string name="add_page">Añadir página</string>
<string name="export_pdf">Exportar PDF</string>
<string name="page_number">Página %1$d</string>
<string name="move_up">Subir</string>
<string name="move_down">Bajar</string>
<string name="delete">Eliminar</string>
<!-- Crop -->
<string name="filter_color">Color</string>
<string name="filter_grayscale">Escala de grises</string>
<string name="filter_bw">Blanco y negro</string>
<string name="rotate">Girar</string>
<string name="reset">Restablecer</string>
<string name="apply">Aplicar</string>
<!-- Export -->
<string name="export_title">Exportar (%1$d páginas)</string>
<string name="file_name">Nombre del archivo</string>
<string name="page_size_a4">Página A4</string>
<string name="page_size_original">Tamaño original</string>
<string name="share_save">Compartir / guardar</string>
<string name="share_pdf">Compartir PDF</string>
<string name="upload_to_paperless">Subir a Paperless</string>
<string name="set_up_paperless">Configurar Paperless</string>
<string name="paperless_not_configured">Configura primero Paperless (URL del servidor + token) en los ajustes para subir.</string>
<string name="uploading">Subiendo…</string>
<string name="working">Procesando…</string>
<string name="dont_wait">No esperar</string>
<string name="export_failed">Error al exportar: %1$s</string>
<string name="error_generic">Error: %1$s</string>
<string name="upload_added">Añadido a Paperless ✓</string>
<string name="upload_duplicate">Ya está en Paperless (duplicado) — no se añadió de nuevo.</string>
<string name="upload_pending">Subido — Paperless aún lo está procesando…</string>
<string name="upload_background">Subido — Paperless terminará de procesarlo en segundo plano.</string>
<!-- Settings -->
<string name="back">Atrás</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">URL del servidor (p. ej. https://paperless.home.lan)</string>
<string name="api_token_label">Token de API</string>
<string name="http_warning">⚠ Estás usando http:// — tu token y tus documentos viajan sin cifrar por la red. Usa https:// si puedes.</string>
<string name="test_connection">Probar conexión</string>
<string name="save">Guardar</string>
<string name="testing">Probando…</string>
<string name="settings_saved">Ajustes guardados ✓</string>
<string name="connection_ok">Conexión correcta ✓</string>
<string name="unexpected_response">Respuesta inesperada del servidor.</string>
<string name="default_filter">Filtro predeterminado</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Token no válido (%1$d).</string>
<string name="err_server_responded">El servidor respondió %1$d.</string>
<string name="err_connection_failed">Error de conexión.</string>
<string name="err_upload_failed_code">Error al subir: %1$d %2$s</string>
<string name="err_upload_failed">Error al subir.</string>
<string name="err_paperless_process">Paperless no pudo procesar el documento.</string>
<string name="err_paperless_not_setup">Paperless aún no está configurado (URL + token).</string>
<string name="err_enter_url_token">Introduce primero una URL y un token.</string>
</resources>

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Camera -->
<string name="camera_permission_rationale">PaperScan a besoin d\'accéder à la caméra pour numériser des documents. Rien n\'est envoyé vers le cloud.</string>
<string name="grant_access">Autoriser l\'accès</string>
<string name="settings">Paramètres</string>
<string name="done">Terminé</string>
<!-- Pages -->
<string name="pages_title">Pages (%1$d)</string>
<string name="add_page">Ajouter une page</string>
<string name="export_pdf">Exporter le PDF</string>
<string name="page_number">Page %1$d</string>
<string name="move_up">Monter</string>
<string name="move_down">Descendre</string>
<string name="delete">Supprimer</string>
<!-- Crop -->
<string name="filter_color">Couleur</string>
<string name="filter_grayscale">Niveaux de gris</string>
<string name="filter_bw">Noir et blanc</string>
<string name="rotate">Pivoter</string>
<string name="reset">Réinitialiser</string>
<string name="apply">Appliquer</string>
<!-- Export -->
<string name="export_title">Exporter (%1$d pages)</string>
<string name="file_name">Nom du fichier</string>
<string name="page_size_a4">Page A4</string>
<string name="page_size_original">Taille d\'origine</string>
<string name="share_save">Partager / enregistrer</string>
<string name="share_pdf">Partager le PDF</string>
<string name="upload_to_paperless">Envoyer vers Paperless</string>
<string name="set_up_paperless">Configurer Paperless</string>
<string name="paperless_not_configured">Configurez d\'abord Paperless (URL du serveur + jeton) dans les paramètres pour envoyer.</string>
<string name="uploading">Envoi en cours…</string>
<string name="working">Traitement…</string>
<string name="dont_wait">Ne pas attendre</string>
<string name="export_failed">Échec de l\'export : %1$s</string>
<string name="error_generic">Erreur : %1$s</string>
<string name="upload_added">Ajouté à Paperless ✓</string>
<string name="upload_duplicate">Déjà dans Paperless (doublon) — non ajouté de nouveau.</string>
<string name="upload_pending">Envoyé — Paperless traite encore le document…</string>
<string name="upload_background">Envoyé — Paperless terminera le traitement en arrière-plan.</string>
<!-- Settings -->
<string name="back">Retour</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">URL du serveur (ex. https://paperless.home.lan)</string>
<string name="api_token_label">Jeton d\'API</string>
<string name="http_warning">⚠ Vous utilisez http:// — votre jeton et vos documents circulent sans chiffrement sur le réseau. Utilisez https:// si possible.</string>
<string name="test_connection">Tester la connexion</string>
<string name="save">Enregistrer</string>
<string name="testing">Test en cours…</string>
<string name="settings_saved">Paramètres enregistrés ✓</string>
<string name="connection_ok">Connexion OK ✓</string>
<string name="unexpected_response">Réponse inattendue du serveur.</string>
<string name="default_filter">Filtre par défaut</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Jeton invalide (%1$d).</string>
<string name="err_server_responded">Le serveur a répondu %1$d.</string>
<string name="err_connection_failed">Échec de la connexion.</string>
<string name="err_upload_failed_code">Échec de l\'envoi : %1$d %2$s</string>
<string name="err_upload_failed">Échec de l\'envoi.</string>
<string name="err_paperless_process">Paperless n\'a pas pu traiter le document.</string>
<string name="err_paperless_not_setup">Paperless n\'est pas encore configuré (URL + jeton).</string>
<string name="err_enter_url_token">Saisissez d\'abord une URL et un jeton.</string>
</resources>

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Camera -->
<string name="camera_permission_rationale">PaperScan ha bisogno dell\'accesso alla fotocamera per scansionare documenti. Non viene inviato nulla al cloud.</string>
<string name="grant_access">Concedi accesso</string>
<string name="settings">Impostazioni</string>
<string name="done">Fatto</string>
<!-- Pages -->
<string name="pages_title">Pagine (%1$d)</string>
<string name="add_page">Aggiungi pagina</string>
<string name="export_pdf">Esporta PDF</string>
<string name="page_number">Pagina %1$d</string>
<string name="move_up">Sposta su</string>
<string name="move_down">Sposta giù</string>
<string name="delete">Elimina</string>
<!-- Crop -->
<string name="filter_color">Colore</string>
<string name="filter_grayscale">Scala di grigi</string>
<string name="filter_bw">Bianco e nero</string>
<string name="rotate">Ruota</string>
<string name="reset">Ripristina</string>
<string name="apply">Applica</string>
<!-- Export -->
<string name="export_title">Esporta (%1$d pagine)</string>
<string name="file_name">Nome file</string>
<string name="page_size_a4">Pagina A4</string>
<string name="page_size_original">Dimensione originale</string>
<string name="share_save">Condividi / salva</string>
<string name="share_pdf">Condividi PDF</string>
<string name="upload_to_paperless">Carica su Paperless</string>
<string name="set_up_paperless">Configura Paperless</string>
<string name="paperless_not_configured">Configura prima Paperless (URL del server + token) nelle impostazioni per caricare.</string>
<string name="uploading">Caricamento…</string>
<string name="working">Elaborazione…</string>
<string name="dont_wait">Non attendere</string>
<string name="export_failed">Esportazione non riuscita: %1$s</string>
<string name="error_generic">Errore: %1$s</string>
<string name="upload_added">Aggiunto a Paperless ✓</string>
<string name="upload_duplicate">Già presente in Paperless (duplicato) — non aggiunto di nuovo.</string>
<string name="upload_pending">Caricato — Paperless lo sta ancora elaborando…</string>
<string name="upload_background">Caricato — Paperless completerà l\'elaborazione in background.</string>
<!-- Settings -->
<string name="back">Indietro</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">URL del server (es. https://paperless.home.lan)</string>
<string name="api_token_label">Token API</string>
<string name="http_warning">⚠ Stai usando http:// — il tuo token e i tuoi documenti viaggiano non cifrati sulla rete. Usa https:// se puoi.</string>
<string name="test_connection">Prova connessione</string>
<string name="save">Salva</string>
<string name="testing">Verifica in corso…</string>
<string name="settings_saved">Impostazioni salvate ✓</string>
<string name="connection_ok">Connessione OK ✓</string>
<string name="unexpected_response">Risposta inattesa dal server.</string>
<string name="default_filter">Filtro predefinito</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Token non valido (%1$d).</string>
<string name="err_server_responded">Il server ha risposto %1$d.</string>
<string name="err_connection_failed">Connessione non riuscita.</string>
<string name="err_upload_failed_code">Caricamento non riuscito: %1$d %2$s</string>
<string name="err_upload_failed">Caricamento non riuscito.</string>
<string name="err_paperless_process">Paperless non è riuscito a elaborare il documento.</string>
<string name="err_paperless_not_setup">Paperless non è ancora configurato (URL + token).</string>
<string name="err_enter_url_token">Inserisci prima un URL e un token.</string>
</resources>

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

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Camera -->
<string name="camera_permission_rationale">O PaperScan precisa de acesso à câmera para digitalizar documentos. Nada é enviado para a nuvem.</string>
<string name="grant_access">Conceder acesso</string>
<string name="settings">Configurações</string>
<string name="done">Concluído</string>
<!-- Pages -->
<string name="pages_title">Páginas (%1$d)</string>
<string name="add_page">Adicionar página</string>
<string name="export_pdf">Exportar PDF</string>
<string name="page_number">Página %1$d</string>
<string name="move_up">Mover para cima</string>
<string name="move_down">Mover para baixo</string>
<string name="delete">Excluir</string>
<!-- Crop -->
<string name="filter_color">Cor</string>
<string name="filter_grayscale">Escala de cinza</string>
<string name="filter_bw">Preto e branco</string>
<string name="rotate">Girar</string>
<string name="reset">Redefinir</string>
<string name="apply">Aplicar</string>
<!-- Export -->
<string name="export_title">Exportar (%1$d páginas)</string>
<string name="file_name">Nome do arquivo</string>
<string name="page_size_a4">Página A4</string>
<string name="page_size_original">Tamanho original</string>
<string name="share_save">Compartilhar / salvar</string>
<string name="share_pdf">Compartilhar PDF</string>
<string name="upload_to_paperless">Enviar para o Paperless</string>
<string name="set_up_paperless">Configurar o Paperless</string>
<string name="paperless_not_configured">Configure primeiro o Paperless (URL do servidor + token) nas configurações para enviar.</string>
<string name="uploading">Enviando…</string>
<string name="working">Processando…</string>
<string name="dont_wait">Não esperar</string>
<string name="export_failed">Falha ao exportar: %1$s</string>
<string name="error_generic">Erro: %1$s</string>
<string name="upload_added">Adicionado ao Paperless ✓</string>
<string name="upload_duplicate">Já está no Paperless (duplicado) — não adicionado novamente.</string>
<string name="upload_pending">Enviado — o Paperless ainda está processando…</string>
<string name="upload_background">Enviado — o Paperless concluirá o processamento em segundo plano.</string>
<!-- Settings -->
<string name="back">Voltar</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">URL do servidor (ex.: https://paperless.home.lan)</string>
<string name="api_token_label">Token de API</string>
<string name="http_warning">⚠ Você está usando http:// — seu token e seus documentos trafegam sem criptografia na rede. Use https:// se possível.</string>
<string name="test_connection">Testar conexão</string>
<string name="save">Salvar</string>
<string name="testing">Testando…</string>
<string name="settings_saved">Configurações salvas ✓</string>
<string name="connection_ok">Conexão OK ✓</string>
<string name="unexpected_response">Resposta inesperada do servidor.</string>
<string name="default_filter">Filtro padrão</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Token inválido (%1$d).</string>
<string name="err_server_responded">O servidor respondeu %1$d.</string>
<string name="err_connection_failed">Falha na conexão.</string>
<string name="err_upload_failed_code">Falha no envio: %1$d %2$s</string>
<string name="err_upload_failed">Falha no envio.</string>
<string name="err_paperless_process">O Paperless não conseguiu processar o documento.</string>
<string name="err_paperless_not_setup">O Paperless ainda não está configurado (URL + token).</string>
<string name="err_enter_url_token">Insira primeiro uma URL e um token.</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>

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