Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e02e69052d | |||
| 898ae76526 | |||
| 3a3f18153f | |||
| 3c77e9ab3e | |||
| 953efaf168 |
BIN
.claude/i18n.png
Normal file
BIN
.claude/i18n.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
.claude/overlay1.png
Normal file
BIN
.claude/overlay1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
.claude/overlay2.png
Normal file
BIN
.claude/overlay2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
.claude/overlay3.png
Normal file
BIN
.claude/overlay3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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
123
README.md
@@ -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.
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ android {
|
|||||||
applicationId = "eu.geyskens.pdfscan"
|
applicationId = "eu.geyskens.pdfscan"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 2
|
versionCode = 6
|
||||||
versionName = "0.1.1"
|
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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
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.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -18,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)
|
||||||
@@ -28,7 +35,12 @@ class PaperlessClient(
|
|||||||
private val root = baseUrl.trim().trimEnd('/')
|
private val root = baseUrl.trim().trimEnd('/')
|
||||||
|
|
||||||
sealed interface Result {
|
sealed interface Result {
|
||||||
|
/** Document was consumed and created in Paperless. */
|
||||||
data object Success : Result
|
data object Success : Result
|
||||||
|
/** Paperless rejected it as a duplicate of an existing document. */
|
||||||
|
data object Duplicate : Result
|
||||||
|
/** Upload was accepted but still processing when we stopped waiting. */
|
||||||
|
data object Pending : Result
|
||||||
data class Failure(val message: String) : Result
|
data class Failure(val message: String) : Result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,14 +56,19 @@ 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)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Uploads a PDF to the Paperless consume/inbox pipeline. */
|
/**
|
||||||
|
* Uploads a PDF and then waits for Paperless to actually consume it. The upload
|
||||||
|
* endpoint only queues the document and returns a task UUID; we poll the task so the
|
||||||
|
* user learns whether it was added, was a duplicate, or failed.
|
||||||
|
*/
|
||||||
suspend fun upload(pdf: File, title: String): Result = withContext(Dispatchers.IO) {
|
suspend fun upload(pdf: File, title: String): Result = withContext(Dispatchers.IO) {
|
||||||
val body = MultipartBody.Builder()
|
val body = MultipartBody.Builder()
|
||||||
.setType(MultipartBody.FORM)
|
.setType(MultipartBody.FORM)
|
||||||
@@ -66,11 +83,74 @@ class PaperlessClient(
|
|||||||
.header("Authorization", "Token $token")
|
.header("Authorization", "Token $token")
|
||||||
.post(body)
|
.post(body)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
// The POST runs NonCancellable so that "stop waiting" only skips the status poll —
|
||||||
|
// the document is always really queued. post_document returns the task UUID string.
|
||||||
|
val posted: kotlin.Result<String?> = withContext(NonCancellable) {
|
||||||
runCatching {
|
runCatching {
|
||||||
client.newCall(request).execute().use { resp ->
|
client.newCall(request).execute().use { resp ->
|
||||||
if (resp.isSuccessful) Result.Success
|
if (!resp.isSuccessful) {
|
||||||
else Result.Failure("Upload mislukt: ${resp.code} ${resp.message}")
|
error(context.getString(R.string.err_upload_failed_code, resp.code, resp.message))
|
||||||
}
|
}
|
||||||
}.getOrElse { Result.Failure(it.message ?: "Upload mislukt.") }
|
resp.body?.string()?.trim()?.trim('"')?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val taskId = posted.getOrElse {
|
||||||
|
return@withContext Result.Failure(it.message ?: context.getString(R.string.err_upload_failed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older servers may not return a usable id; then we can only confirm acceptance.
|
||||||
|
if (taskId == null || taskId.equals("OK", ignoreCase = true)) return@withContext Result.Pending
|
||||||
|
|
||||||
|
pollTask(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polls /api/tasks/ until the consume task finishes, or gives up after ~45s. */
|
||||||
|
private suspend fun pollTask(taskId: String): Result {
|
||||||
|
val deadline = System.currentTimeMillis() + 45_000
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
val task = runCatching {
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$root/api/tasks/?task_id=$taskId")
|
||||||
|
.header("Authorization", "Token $token")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
client.newCall(req).execute().use { resp ->
|
||||||
|
if (resp.isSuccessful) firstTask(resp.body?.string()) else null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
when (task?.status?.uppercase()) {
|
||||||
|
"SUCCESS" -> return Result.Success
|
||||||
|
"FAILURE" -> {
|
||||||
|
val msg = task.result.orEmpty()
|
||||||
|
return if (msg.contains("duplicate", ignoreCase = true)) Result.Duplicate
|
||||||
|
else Result.Failure(msg.ifBlank { context.getString(R.string.err_paperless_process) })
|
||||||
|
}
|
||||||
|
// PENDING / STARTED / RETRY / unknown → keep waiting.
|
||||||
|
}
|
||||||
|
delay(1500)
|
||||||
|
}
|
||||||
|
return Result.Pending
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Task(val status: String?, val result: String?)
|
||||||
|
|
||||||
|
/** Parses the first task object from the /api/tasks/ response (a plain array). */
|
||||||
|
private fun firstTask(json: String?): Task? {
|
||||||
|
if (json.isNullOrBlank()) return null
|
||||||
|
return runCatching {
|
||||||
|
val trimmed = json.trim()
|
||||||
|
val arr = if (trimmed.startsWith("[")) JSONArray(trimmed)
|
||||||
|
else JSONObject(trimmed).optJSONArray("results")
|
||||||
|
if (arr == null || arr.length() == 0) return null
|
||||||
|
val o = arr.getJSONObject(0)
|
||||||
|
Task(
|
||||||
|
status = o.optString("status").takeIf { it.isNotBlank() && it != "null" },
|
||||||
|
result = o.optString("result").takeIf { it.isNotBlank() && it != "null" },
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ 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
|
||||||
import eu.geyskens.pdfscan.pdf.PageSizeMode
|
import eu.geyskens.pdfscan.pdf.PageSizeMode
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -61,12 +65,14 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
|||||||
var fileName by remember { mutableStateOf(defaultName) }
|
var fileName by remember { mutableStateOf(defaultName) }
|
||||||
var a4 by remember { mutableStateOf(true) }
|
var a4 by remember { mutableStateOf(true) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var uploading by remember { mutableStateOf(false) }
|
||||||
|
var uploadJob by remember { mutableStateOf<Job?>(null) }
|
||||||
var status by remember { mutableStateOf<String?>(null) }
|
var status by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
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),
|
||||||
@@ -75,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(),
|
||||||
)
|
)
|
||||||
@@ -85,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(
|
||||||
@@ -109,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
|
||||||
}
|
}
|
||||||
@@ -122,27 +128,36 @@ 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(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (busy) return@Button
|
if (busy) return@Button
|
||||||
busy = true
|
busy = true
|
||||||
|
uploading = true
|
||||||
status = null
|
status = null
|
||||||
scope.launch {
|
uploadJob = scope.launch {
|
||||||
try {
|
try {
|
||||||
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 = "Geüpload naar Paperless ✓"
|
status = context.getString(R.string.upload_added)
|
||||||
|
is PaperlessClient.Result.Duplicate ->
|
||||||
|
status = context.getString(R.string.upload_duplicate)
|
||||||
|
is PaperlessClient.Result.Pending ->
|
||||||
|
status = context.getString(R.string.upload_pending)
|
||||||
is PaperlessClient.Result.Failure ->
|
is PaperlessClient.Result.Failure ->
|
||||||
status = r.message
|
status = r.message
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// User tapped "Don't wait": the document was already sent.
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -151,26 +166,30 @@ 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("Bezig…")
|
Text(if (uploading) stringResource(R.string.uploading) else stringResource(R.string.working))
|
||||||
|
if (uploading) {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
OutlinedButton(onClick = { uploadJob?.cancel() }) { Text(stringResource(R.string.dont_wait)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
status?.let { Text(it, color = Color(0xFF1F6FEB)) }
|
status?.let { Text(it, color = Color(0xFF1F6FEB)) }
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,54 +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 -> 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)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
app/src/main/res/values-de/strings.xml
Normal file
69
app/src/main/res/values-de/strings.xml
Normal 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>
|
||||||
69
app/src/main/res/values-es/strings.xml
Normal file
69
app/src/main/res/values-es/strings.xml
Normal 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>
|
||||||
69
app/src/main/res/values-fr/strings.xml
Normal file
69
app/src/main/res/values-fr/strings.xml
Normal 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>
|
||||||
69
app/src/main/res/values-it/strings.xml
Normal file
69
app/src/main/res/values-it/strings.xml
Normal 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>
|
||||||
69
app/src/main/res/values-nl/strings.xml
Normal file
69
app/src/main/res/values-nl/strings.xml
Normal 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>
|
||||||
69
app/src/main/res/values-pt/strings.xml
Normal file
69
app/src/main/res/values-pt/strings.xml
Normal 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>
|
||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user