commit 037550afa9acb83c1a92cda53f3f7cee018b6a3c Author: sam Date: Sun Jul 12 22:43:09 2026 +0200 Initial commit: PaperScan — lokale Android documentscanner voor Paperless-ngx Native Kotlin/Compose app met CameraX + OpenCV randdetectie/perspectiefcorrectie, multi-page PDF-export, en Paperless-ngx upload (of Android deelmenu). Alles lokaal. Co-Authored-By: Claude Opus 4.8 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6b7f707 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(java -version)", + "WebFetch(domain:repo1.maven.org)", + "Bash(curl -s https://repo1.maven.org/maven2/org/opencv/opencv/maven-metadata.xml)" + ] + } +} diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..220efab --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,46 @@ +name: Build APK + +# Bouwt een getekende release-APK op elke versietag (v0.1.0, …) en op handmatige trigger. +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + # Image met Android SDK + JDK 17 + Gradle voorgeïnstalleerd. + container: + image: mingc/android-build-box:latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Herstel signing keystore uit secret + run: | + echo "$SIGNING_KEYSTORE_BASE64" | base64 -d > "$GITHUB_WORKSPACE/release.jks" + env: + SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }} + + - name: Genereer Gradle wrapper indien nodig + run: | + if [ ! -f gradle/wrapper/gradle-wrapper.jar ]; then + gradle wrapper --gradle-version 8.11.1 + fi + + - name: Build release APK + run: | + chmod +x ./gradlew + ./gradlew --no-daemon assembleRelease + env: + SIGNING_STORE_FILE: ${{ github.workspace }}/release.jks + SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} + SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + + - name: Upload APK als artifact + uses: actions/upload-artifact@v3 + with: + name: paperscan-release + path: app/build/outputs/apk/release/*.apk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0b93e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build +/app/build +/captures +.externalNativeBuild +.cxx +local.properties + +# Signing material must never be committed. +keystore.properties +*.jks +*.keystore diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a4881e --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# PaperScan + +Een lokale, privacy-bewuste documentscanner voor Android. Scant papieren documenten +met de camera, detecteert automatisch de randen, corrigeert het perspectief en bundelt +meerdere pagina's in één PDF. Die PDF kun je delen via Android of rechtstreeks uploaden +naar je zelf-gehoste **Paperless-ngx**. + +**Alles gebeurt op het toestel.** Camerabeelden, randdetectie (OpenCV) en PDF-creatie +verlaten je telefoon niet. De enige netwerkverbinding is de optionele upload naar de +Paperless-server die jij zelf configureert. Er is geen AI of cloud-verwerking. + +## Functies + +- 📷 Camerapreview met multi-page vastleggen (pagina na pagina toevoegen) +- ✂️ Automatische randdetectie + perspectiefcorrectie (OpenCV), met handmatige hoekcorrectie +- 🎨 Filters per pagina: kleur, grijstinten, zwart-wit (documentmodus) +- 🔄 Pagina's draaien, herordenen en verwijderen +- 📄 Export naar multi-page PDF (A4 of originele grootte) +- ☁️ Directe upload naar Paperless-ngx **of** delen via het Android-deelmenu +- 🔐 API-token versleuteld opgeslagen (Android Keystore / EncryptedSharedPreferences) + +## Tech + +Kotlin · Jetpack Compose · CameraX · OpenCV (`org.opencv:opencv` via Maven) · +`PdfDocument` · OkHttp · DataStore. minSdk 26 (Android 8), target/compile SDK 35. + +## Lokaal bouwen + +1. Open het project in **Android Studio** (Ladybug of nieuwer). Dit genereert automatisch + de Gradle wrapper (`gradlew` + `gradle-wrapper.jar`). +2. Sluit een toestel aan of start een emulator en druk op **Run**. + +Of vanaf de command line (na `gradle wrapper` één keer): + +```bash +./gradlew assembleDebug # debug-APK, geen signing nodig +./gradlew assembleRelease # release-APK, vereist signing (zie onder) +``` + +De release-APK verschijnt in `app/build/outputs/apk/release/`. + +## Signing key aanmaken + +Nodig voor release-builds (en dus voor updates die elkaar kunnen overschrijven). + +```bash +keytool -genkey -v -keystore release.jks -keyalg RSA -keysize 2048 \ + -validity 10000 -alias paperscan +``` + +Bewaar `release.jks` **veilig en buiten git** (staat al in `.gitignore`). + +Voor lokale release-builds maak je een `keystore.properties` in de projectroot +(zie `keystore.properties.example`): + +```properties +storeFile=/pad/naar/release.jks +storePassword=... +keyAlias=paperscan +keyPassword=... +``` + +## Bouwen via Gitea Actions + +De workflow `.gitea/workflows/build.yml` bouwt een getekende release-APK bij elke +versietag (`git tag v0.1.0 && git push --tags`) of via een handmatige run. + +Zet in je Gitea-repo onder **Settings → Actions → Secrets** deze secrets klaar: + +| Secret | Inhoud | +| --- | --- | +| `SIGNING_KEYSTORE_BASE64` | `base64 -w0 release.jks` (de keystore als base64) | +| `SIGNING_STORE_PASSWORD` | wachtwoord van de keystore | +| `SIGNING_KEY_ALIAS` | bv. `paperscan` | +| `SIGNING_KEY_PASSWORD` | wachtwoord van de sleutel | + +De build gebruikt env-vars, dus er komt niets gevoeligs in de repo terecht. De APK +komt als artifact `paperscan-release` beschikbaar bij de workflow-run. + +> Zorg dat je Gitea Actions-runner Docker-images kan trekken (`mingc/android-build-box`). + +## Paperless-ngx instellen (in de app) + +1. Maak in Paperless een API-token aan: **Instellingen → profiel → API-token** + (of via `/api/token/`). +2. Open in PaperScan het tandwiel → vul **server-URL** (bv. `https://paperless.thuis.lan`) + en het **token** in → **Verbinding testen** → **Opslaan**. + +Gebruik bij voorkeur **https**; bij `http://` waarschuwt de app dat token en documenten +onversleuteld over het netwerk gaan. + +## Projectstructuur + +``` +app/src/main/java/eu/geyskens/pdfscan/ +├── PaperScanApp.kt # Application; laadt OpenCV native libs +├── MainActivity.kt # Compose host + navigatie +├── ScanViewModel.kt # scan-sessie, rendering, export, upload +├── data/ # Page-model, ScanFilter, SettingsRepository +├── scan/ # EdgeDetector, ImageProcessor, BitmapIo (OpenCV) +├── pdf/ # PdfBuilder (multi-page PDF) +├── paperless/ # PaperlessClient (OkHttp REST) +└── ui/screens/ # Camera, Pages, Crop, Export, Settings +``` + +## Roadmap / ideeën + +- Live randdetectie-overlay tijdens het richten (nu bij vastleggen) +- OCR-tekstlaag (bv. Tesseract) volledig on-device +- Batch-import van bestaande foto's uit de galerij diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ef6f7dd --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,110 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +// Release signing is driven either by a local keystore.properties (developer machine) +// or by environment variables (Gitea Actions CI). Nothing secret is committed. +val keystorePropsFile = rootProject.file("keystore.properties") +val keystoreProps = Properties().apply { + if (keystorePropsFile.exists()) load(keystorePropsFile.inputStream()) +} + +fun signingValue(key: String, env: String): String? = + keystoreProps.getProperty(key) ?: System.getenv(env) + +android { + namespace = "eu.geyskens.pdfscan" + compileSdk = 35 + + defaultConfig { + applicationId = "eu.geyskens.pdfscan" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + + // OpenCV native libs are large; ship only the ABIs real phones use. + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + } + + val storePath = signingValue("storeFile", "SIGNING_STORE_FILE") + signingConfigs { + if (storePath != null) { + create("release") { + storeFile = file(storePath) + storePassword = signingValue("storePassword", "SIGNING_STORE_PASSWORD") + keyAlias = signingValue("keyAlias", "SIGNING_KEY_ALIAS") + keyPassword = signingValue("keyPassword", "SIGNING_KEY_PASSWORD") + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.findByName("release") + } + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + + implementation(libs.opencv) + implementation(libs.okhttp) + + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.security.crypto) + implementation(libs.accompanist.permissions) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.concurrent.futures.ktx) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..3857f92 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,8 @@ +# OpenCV loads native classes via JNI; keep its Java surface intact. +-keep class org.opencv.** { *; } +-dontwarn org.opencv.** + +# OkHttp / Okio +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a8216cc --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/eu/geyskens/pdfscan/MainActivity.kt b/app/src/main/java/eu/geyskens/pdfscan/MainActivity.kt new file mode 100644 index 0000000..f54c6a3 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/MainActivity.kt @@ -0,0 +1,62 @@ +package eu.geyskens.pdfscan + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import eu.geyskens.pdfscan.ui.screens.CameraScreen +import eu.geyskens.pdfscan.ui.screens.CropScreen +import eu.geyskens.pdfscan.ui.screens.ExportScreen +import eu.geyskens.pdfscan.ui.screens.PagesScreen +import eu.geyskens.pdfscan.ui.screens.SettingsScreen +import eu.geyskens.pdfscan.ui.theme.PaperScanTheme + +object Routes { + const val CAMERA = "camera" + const val PAGES = "pages" + const val CROP = "crop/{pageId}" + const val EXPORT = "export" + const val SETTINGS = "settings" + fun crop(pageId: String) = "crop/$pageId" +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + PaperScanTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + // Activity-scoped so every destination shares one scan session. + val vm: ScanViewModel = viewModel() + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = Routes.CAMERA) { + composable(Routes.CAMERA) { CameraScreen(vm, navController) } + composable(Routes.PAGES) { PagesScreen(vm, navController) } + composable(Routes.CROP) { backStack -> + CropScreen( + vm = vm, + navController = navController, + pageId = backStack.arguments?.getString("pageId").orEmpty(), + ) + } + composable(Routes.EXPORT) { ExportScreen(vm, navController) } + composable(Routes.SETTINGS) { SettingsScreen(vm, navController) } + } + } + } + } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/PaperScanApp.kt b/app/src/main/java/eu/geyskens/pdfscan/PaperScanApp.kt new file mode 100644 index 0000000..2397923 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/PaperScanApp.kt @@ -0,0 +1,15 @@ +package eu.geyskens.pdfscan + +import android.app.Application +import android.util.Log +import org.opencv.android.OpenCVLoader + +class PaperScanApp : Application() { + override fun onCreate() { + super.onCreate() + // The org.opencv:opencv Maven artifact bundles the native libs; initLocal loads them. + if (!OpenCVLoader.initLocal()) { + Log.e("PaperScan", "OpenCV native init failed; edge detection will fall back to full-frame.") + } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt b/app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt new file mode 100644 index 0000000..4106456 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt @@ -0,0 +1,147 @@ +package eu.geyskens.pdfscan + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.PointF +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import eu.geyskens.pdfscan.data.AppSettings +import eu.geyskens.pdfscan.data.Page +import eu.geyskens.pdfscan.data.ScanFilter +import eu.geyskens.pdfscan.data.SettingsRepository +import eu.geyskens.pdfscan.paperless.PaperlessClient +import eu.geyskens.pdfscan.pdf.PageSizeMode +import eu.geyskens.pdfscan.pdf.PdfBuilder +import eu.geyskens.pdfscan.scan.BitmapIo +import eu.geyskens.pdfscan.scan.EdgeDetector +import eu.geyskens.pdfscan.scan.ImageProcessor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import java.io.File + +/** Owns the in-progress scan session: the list of pages, their edits, and export. */ +class ScanViewModel(app: Application) : AndroidViewModel(app) { + + private val settingsRepo = SettingsRepository(app) + val settings: StateFlow = + settingsRepo.settings.stateIn(viewModelScope, SharingStarted.Eagerly, AppSettings()) + + private val _pages = MutableStateFlow>(emptyList()) + val pages: StateFlow> = _pages.asStateFlow() + + private val capturesDir = File(app.cacheDir, "captures").apply { mkdirs() } + private val exportsDir = File(app.cacheDir, "exports").apply { mkdirs() } + + // Rendered-page cache keyed by page id, invalidated when the page's edit hash changes. + private val renderCache = HashMap>() + + fun capturesDir(): File = capturesDir + + /** Called after CameraX writes a capture; runs edge detection and appends a page. */ + suspend fun onImageCaptured(file: File) = withContext(Dispatchers.Default) { + val bitmap = BitmapIo.loadOriented(file) + val corners = EdgeDetector.detect(bitmap) ?: Page.fullFrameCorners() + bitmap.recycle() + val page = Page( + sourceFile = file, + corners = corners, + filter = settings.value.defaultFilter, + ) + _pages.value = _pages.value + page + } + + fun page(id: String): Page? = _pages.value.firstOrNull { it.id == id } + + /** Loads the raw, EXIF-oriented capture (not perspective-corrected) for the crop overlay. */ + suspend fun loadSource(page: Page): Bitmap = + withContext(Dispatchers.Default) { BitmapIo.loadOriented(page.sourceFile) } + + fun updateCorners(id: String, corners: List) = mutate(id) { it.copy(corners = corners) } + fun setFilter(id: String, filter: ScanFilter) = mutate(id) { it.copy(filter = filter) } + fun rotate(id: String) = mutate(id) { it.copy(rotationDegrees = (it.rotationDegrees + 90) % 360) } + fun resetCorners(id: String) = mutate(id) { it.copy(corners = Page.fullFrameCorners()) } + + fun delete(id: String) { + _pages.value.firstOrNull { it.id == id }?.sourceFile?.delete() + // Don't recycle here: a leaving composition may still be drawing this bitmap. + renderCache.remove(id) + _pages.value = _pages.value.filterNot { it.id == id } + } + + fun move(from: Int, to: Int) { + val list = _pages.value.toMutableList() + if (from in list.indices && to in list.indices) { + list.add(to, list.removeAt(from)) + _pages.value = list + } + } + + private inline fun mutate(id: String, transform: (Page) -> Page) { + _pages.value = _pages.value.map { if (it.id == id) transform(it) else it } + // Drop the stale render but let GC reclaim it; recycling now can hit a live draw. + renderCache.remove(id) + } + + /** Renders a page to a deskewed, filtered bitmap. Cached until the page is edited. */ + suspend fun render(page: Page): Bitmap = withContext(Dispatchers.Default) { + val hash = editHash(page) + renderCache[page.id]?.let { (h, bmp) -> if (h == hash && !bmp.isRecycled) return@withContext bmp } + val source = BitmapIo.loadOriented(page.sourceFile) + val result = ImageProcessor.process(source, page.corners, page.rotationDegrees, page.filter) + source.recycle() + renderCache[page.id] = hash to result + result + } + + private fun editHash(page: Page): Int = + listOf(page.corners, page.rotationDegrees, page.filter, page.sourceFile.path).hashCode() + + /** Builds the multi-page PDF into the shareable exports dir and returns it. */ + suspend fun exportPdf(fileName: String, mode: PageSizeMode = PageSizeMode.A4): File = + withContext(Dispatchers.Default) { + val safe = fileName.ifBlank { "scan" }.replace(Regex("[^A-Za-z0-9-_ ]"), "_") + val rendered = _pages.value.map { render(it) } + val out = File(exportsDir, "$safe.pdf") + PdfBuilder.build(rendered, out, mode) + out + } + + suspend fun uploadToPaperless(pdf: File, title: String): PaperlessClient.Result { + val s = settings.value + val token = settingsRepo.getToken() + if (s.paperlessUrl.isBlank() || token.isNullOrBlank()) { + return PaperlessClient.Result.Failure("Paperless is nog niet ingesteld (URL + token).") + } + return PaperlessClient(s.paperlessUrl, token).upload(pdf, title) + } + + // --- Settings pass-throughs (SettingsScreen) --- + + fun currentToken(): String? = settingsRepo.getToken() + + suspend fun savePaperless(url: String, token: String) { + settingsRepo.setPaperlessUrl(url.trim()) + settingsRepo.setToken(token.trim()) + } + + suspend fun setDefaultFilter(filter: ScanFilter) = settingsRepo.setDefaultFilter(filter) + + suspend fun testPaperless(url: String, token: String): PaperlessClient.Result { + if (url.isBlank() || token.isBlank()) { + return PaperlessClient.Result.Failure("Vul eerst URL en token in.") + } + return PaperlessClient(url.trim(), token.trim()).testConnection() + } + + fun clearSession() { + _pages.value.forEach { it.sourceFile.delete() } + renderCache.values.forEach { it.second.recycle() } + renderCache.clear() + _pages.value = emptyList() + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/data/Page.kt b/app/src/main/java/eu/geyskens/pdfscan/data/Page.kt new file mode 100644 index 0000000..9799352 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/data/Page.kt @@ -0,0 +1,34 @@ +package eu.geyskens.pdfscan.data + +import android.graphics.PointF +import java.io.File +import java.util.UUID + +/** How a page is rendered before it goes into the PDF. */ +enum class ScanFilter { COLOR, GRAYSCALE, BLACK_WHITE } + +/** + * A single scanned page. + * + * [corners] are the four document corners in the coordinate space of the source + * bitmap, normalized to 0..1, ordered top-left, top-right, bottom-right, bottom-left. + * Storing them normalized keeps them valid regardless of any downscaling we do for + * on-screen preview versus full-resolution export. + */ +data class Page( + val id: String = UUID.randomUUID().toString(), + val sourceFile: File, + val corners: List, + val rotationDegrees: Int = 0, + val filter: ScanFilter = ScanFilter.COLOR, +) { + companion object { + /** Corners covering the whole frame — the fallback when detection finds nothing. */ + fun fullFrameCorners(): List = listOf( + PointF(0f, 0f), + PointF(1f, 0f), + PointF(1f, 1f), + PointF(0f, 1f), + ) + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/data/SettingsRepository.kt b/app/src/main/java/eu/geyskens/pdfscan/data/SettingsRepository.kt new file mode 100644 index 0000000..7e4065b --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/data/SettingsRepository.kt @@ -0,0 +1,69 @@ +package eu.geyskens.pdfscan.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +data class AppSettings( + val paperlessUrl: String = "", + val hasToken: Boolean = false, + val defaultFilter: ScanFilter = ScanFilter.COLOR, +) + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +/** + * Persists app settings. Non-secret values live in DataStore; the Paperless API token + * is kept in an AES-256 EncryptedSharedPreferences file backed by the Android Keystore, + * so it is never stored in plaintext on the device. + */ +class SettingsRepository(private val context: Context) { + + private object Keys { + val URL = stringPreferencesKey("paperless_url") + val FILTER = stringPreferencesKey("default_filter") + } + + private val securePrefs by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "secure_settings", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + val settings: Flow = context.dataStore.data.map { prefs -> + AppSettings( + paperlessUrl = prefs[Keys.URL].orEmpty(), + hasToken = !getToken().isNullOrBlank(), + defaultFilter = prefs[Keys.FILTER]?.let { runCatching { ScanFilter.valueOf(it) }.getOrNull() } + ?: ScanFilter.COLOR, + ) + } + + fun getToken(): String? = securePrefs.getString("paperless_token", null) + + fun setToken(token: String) { + securePrefs.edit().putString("paperless_token", token).apply() + } + + suspend fun setPaperlessUrl(url: String) { + context.dataStore.edit { it[Keys.URL] = url } + } + + suspend fun setDefaultFilter(filter: ScanFilter) { + context.dataStore.edit { it[Keys.FILTER] = filter.name } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt b/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt new file mode 100644 index 0000000..acd2e52 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt @@ -0,0 +1,76 @@ +package eu.geyskens.pdfscan.paperless + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Minimal Paperless-ngx REST client. Talks only to the user-configured, self-hosted + * instance. The token is sent as an `Authorization: Token …` header over the connection + * the user set up (HTTPS strongly recommended — see SettingsScreen guidance). + */ +class PaperlessClient( + private val baseUrl: String, + private val token: String, +) { + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val root = baseUrl.trim().trimEnd('/') + + sealed interface Result { + data object Success : Result + data class Failure(val message: String) : Result + } + + /** Verifies the URL + token by hitting an authenticated endpoint. */ + suspend fun testConnection(): Result = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$root/api/documents/?page_size=1") + .header("Authorization", "Token $token") + .header("Accept", "application/json") + .get() + .build() + runCatching { + client.newCall(request).execute().use { resp -> + when { + resp.isSuccessful -> Result.Success + resp.code == 401 || resp.code == 403 -> Result.Failure("Ongeldig token (${resp.code}).") + else -> Result.Failure("Server antwoordde met ${resp.code}.") + } + } + }.getOrElse { Result.Failure(it.message ?: "Verbinding mislukt.") } + } + + /** Uploads a PDF to the Paperless consume/inbox pipeline. */ + suspend fun upload(pdf: File, title: String): Result = withContext(Dispatchers.IO) { + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("title", title) + .addFormDataPart( + "document", "$title.pdf", + pdf.asRequestBody("application/pdf".toMediaType()) + ) + .build() + val request = Request.Builder() + .url("$root/api/documents/post_document/") + .header("Authorization", "Token $token") + .post(body) + .build() + runCatching { + client.newCall(request).execute().use { resp -> + if (resp.isSuccessful) Result.Success + else Result.Failure("Upload mislukt: ${resp.code} ${resp.message}") + } + }.getOrElse { Result.Failure(it.message ?: "Upload mislukt.") } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/pdf/PdfBuilder.kt b/app/src/main/java/eu/geyskens/pdfscan/pdf/PdfBuilder.kt new file mode 100644 index 0000000..7a783e3 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/pdf/PdfBuilder.kt @@ -0,0 +1,60 @@ +package eu.geyskens.pdfscan.pdf + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.pdf.PdfDocument +import java.io.File +import java.io.FileOutputStream + +/** How each PDF page is sized relative to its scanned image. */ +enum class PageSizeMode { A4, ORIGINAL } + +object PdfBuilder { + + // A4 in PostScript points (1/72"): 210 x 297 mm. + private const val A4_WIDTH_PT = 595 + private const val A4_HEIGHT_PT = 842 + + /** + * Writes [pages] into a single multi-page PDF at [output]. + * With [PageSizeMode.A4] each image is centered and scaled to fit an A4 page + * (portrait or landscape chosen per image); with ORIGINAL the page matches the image. + */ + fun build(pages: List, output: File, mode: PageSizeMode = PageSizeMode.A4) { + require(pages.isNotEmpty()) { "Cannot build a PDF with no pages." } + val doc = PdfDocument() + val paint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG) + try { + pages.forEachIndexed { index, bitmap -> + val (pageW, pageH) = when (mode) { + PageSizeMode.ORIGINAL -> bitmap.width to bitmap.height + PageSizeMode.A4 -> + if (bitmap.width >= bitmap.height) A4_HEIGHT_PT to A4_WIDTH_PT // landscape + else A4_WIDTH_PT to A4_HEIGHT_PT + } + val info = PdfDocument.PageInfo.Builder(pageW, pageH, index + 1).create() + val page = doc.startPage(info) + val canvas = page.canvas + canvas.drawColor(Color.WHITE) + canvas.drawBitmap(bitmap, null, fitRect(bitmap, pageW, pageH), paint) + doc.finishPage(page) + } + FileOutputStream(output).use { doc.writeTo(it) } + } finally { + doc.close() + } + } + + /** Largest centered rect inside the page that preserves the bitmap's aspect ratio. */ + private fun fitRect(bitmap: Bitmap, pageW: Int, pageH: Int): Rect { + val scale = minOf(pageW.toFloat() / bitmap.width, pageH.toFloat() / bitmap.height) + val w = (bitmap.width * scale).toInt() + val h = (bitmap.height * scale).toInt() + val left = (pageW - w) / 2 + val top = (pageH - h) / 2 + return Rect(left, top, left + w, top + h) + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt b/app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt new file mode 100644 index 0000000..25c4c5f --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt @@ -0,0 +1,45 @@ +package eu.geyskens.pdfscan.scan + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import androidx.exifinterface.media.ExifInterface +import java.io.File + +/** Loads captured JPEGs, honoring EXIF orientation and capping resolution for memory safety. */ +object BitmapIo { + + private const val MAX_DIMENSION = 2600 + + fun loadOriented(file: File): Bitmap { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + + var sample = 1 + val longest = maxOf(bounds.outWidth, bounds.outHeight) + while (longest / sample > MAX_DIMENSION) sample *= 2 + + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + val bitmap = BitmapFactory.decodeFile(file.absolutePath, opts) + ?: error("Kon afbeelding niet decoderen: ${file.name}") + + val orientation = ExifInterface(file.absolutePath) + .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + return applyExifOrientation(bitmap, orientation) + } + + private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap { + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) + else -> return bitmap + } + val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + if (rotated != bitmap) bitmap.recycle() + return rotated + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt b/app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt new file mode 100644 index 0000000..9072cbd --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt @@ -0,0 +1,81 @@ +package eu.geyskens.pdfscan.scan + +import android.graphics.Bitmap +import android.graphics.PointF +import org.opencv.android.Utils +import org.opencv.core.Mat +import org.opencv.core.MatOfPoint +import org.opencv.core.MatOfPoint2f +import org.opencv.core.Size +import org.opencv.imgproc.Imgproc + +/** + * Finds the largest document-like quadrilateral in an image using OpenCV. + * All heavy work runs on a downscaled copy; corners are returned normalized + * to 0..1 so callers can map them onto any resolution. + */ +object EdgeDetector { + + private const val PROCESS_WIDTH = 500.0 + + /** @return four normalized corners (TL, TR, BR, BL), or null if nothing convincing was found. */ + fun detect(bitmap: Bitmap): List? { + val src = Mat() + 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() + Imgproc.cvtColor(work, gray, Imgproc.COLOR_RGBA2GRAY) + Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0) + + val edges = Mat() + Imgproc.Canny(gray, edges, 50.0, 150.0) + // Close small gaps so contours form a continuous loop. + val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0)) + Imgproc.dilate(edges, edges, kernel) + + val contours = ArrayList() + Imgproc.findContours( + edges, contours, Mat(), + Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE + ) + + val frameArea = work.rows() * work.cols() + var best: List? = null + var bestArea = frameArea * 0.15 // ignore quads smaller than 15% of the frame + + for (contour in contours) { + val c2f = MatOfPoint2f(*contour.toArray()) + val peri = Imgproc.arcLength(c2f, true) + val approx = MatOfPoint2f() + Imgproc.approxPolyDP(c2f, approx, 0.02 * peri, true) + + if (approx.total() == 4L && Imgproc.isContourConvex(MatOfPoint(*approx.toArray()))) { + val area = Imgproc.contourArea(approx) + if (area > bestArea) { + bestArea = area + best = orderCorners(approx.toArray().map { + PointF((it.x / work.cols()).toFloat(), (it.y / work.rows()).toFloat()) + }) + } + } + } + return best + } finally { + src.release() + } + } + + /** Sort four points into TL, TR, BR, BL by their x+y / x-y sums. */ + fun orderCorners(points: List): List { + require(points.size == 4) + val tl = points.minByOrNull { it.x + it.y }!! + val br = points.maxByOrNull { it.x + it.y }!! + val tr = points.maxByOrNull { it.x - it.y }!! + val bl = points.minByOrNull { it.x - it.y }!! + return listOf(tl, tr, br, bl) + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt b/app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt new file mode 100644 index 0000000..e8e97b9 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt @@ -0,0 +1,105 @@ +package eu.geyskens.pdfscan.scan + +import android.graphics.Bitmap +import android.graphics.PointF +import eu.geyskens.pdfscan.data.ScanFilter +import org.opencv.android.Utils +import org.opencv.core.Mat +import org.opencv.core.MatOfPoint2f +import org.opencv.core.Point +import org.opencv.core.Size +import org.opencv.imgproc.Imgproc +import kotlin.math.hypot +import kotlin.math.max + +/** + * Turns a raw capture into a clean, deskewed page: perspective-corrects to the + * given corners, rotates, then applies the chosen look. Fully on-device. + */ +object ImageProcessor { + + /** + * @param corners normalized (0..1) TL, TR, BR, BL in [source] space. + * @param rotationDegrees multiple of 90 applied after warping. + */ + fun process( + source: Bitmap, + corners: List, + rotationDegrees: Int, + filter: ScanFilter, + ): Bitmap { + val src = Mat() + Utils.bitmapToMat(source, src) + try { + val w = src.cols().toDouble() + val h = src.rows().toDouble() + val pts = corners.map { Point(it.x * w, it.y * h) } + val (tl, tr, br, bl) = pts + + val widthTop = hypot(tr.x - tl.x, tr.y - tl.y) + val widthBottom = hypot(br.x - bl.x, br.y - bl.y) + val heightLeft = hypot(bl.x - tl.x, bl.y - tl.y) + val heightRight = hypot(br.x - tr.x, br.y - tr.y) + val outW = max(widthTop, widthBottom).toInt().coerceAtLeast(1) + val outH = max(heightLeft, heightRight).toInt().coerceAtLeast(1) + + val srcQuad = MatOfPoint2f(tl, tr, br, bl) + val dstQuad = MatOfPoint2f( + Point(0.0, 0.0), + Point(outW - 1.0, 0.0), + Point(outW - 1.0, outH - 1.0), + Point(0.0, outH - 1.0), + ) + val transform = Imgproc.getPerspectiveTransform(srcQuad, dstQuad) + val warped = Mat() + Imgproc.warpPerspective(src, warped, transform, Size(outW.toDouble(), outH.toDouble())) + + applyFilter(warped, filter) + rotate(warped, rotationDegrees) + + val out = Bitmap.createBitmap(warped.cols(), warped.rows(), Bitmap.Config.ARGB_8888) + Utils.matToBitmap(warped, out) + warped.release() + return out + } finally { + src.release() + } + } + + private fun applyFilter(mat: Mat, filter: ScanFilter) { + when (filter) { + ScanFilter.COLOR -> { + // Gentle contrast/brightness lift so paper reads as white. + mat.convertTo(mat, -1, 1.15, 8.0) + } + ScanFilter.GRAYSCALE -> { + val gray = Mat() + Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY) + Imgproc.cvtColor(gray, mat, Imgproc.COLOR_GRAY2RGBA) + gray.release() + } + ScanFilter.BLACK_WHITE -> { + val gray = Mat() + Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY) + val bw = Mat() + Imgproc.adaptiveThreshold( + gray, bw, 255.0, + Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY, + 15, 10.0 + ) + Imgproc.cvtColor(bw, mat, Imgproc.COLOR_GRAY2RGBA) + gray.release() + bw.release() + } + } + } + + private fun rotate(mat: Mat, degrees: Int) { + when (((degrees % 360) + 360) % 360) { + 90 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_90_CLOCKWISE) + 180 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_180) + 270 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_90_COUNTERCLOCKWISE) + else -> {} + } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt new file mode 100644 index 0000000..84b9506 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt @@ -0,0 +1,188 @@ +package eu.geyskens.pdfscan.ui.screens + +import android.Manifest +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.concurrent.futures.await +import androidx.core.content.ContextCompat +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import eu.geyskens.pdfscan.Routes +import eu.geyskens.pdfscan.ScanViewModel +import kotlinx.coroutines.launch +import java.io.File +import java.util.concurrent.Executors + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraScreen(vm: ScanViewModel, navController: androidx.navigation.NavController) { + val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA) + + Box(Modifier.fillMaxSize().background(Color.Black)) { + if (cameraPermission.status.isGranted) { + CameraContent(vm, navController) + } else { + PermissionRequest(onRequest = { cameraPermission.launchPermissionRequest() }) + } + } +} + +@Composable +private fun PermissionRequest(onRequest: () -> Unit) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "PaperScan heeft cameratoegang nodig om documenten te scannen. " + + "Er wordt niets naar de cloud gestuurd.", + color = Color.White, + textAlign = TextAlign.Center, + ) + androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp)) + Button(onClick = onRequest) { Text("Toegang geven") } + } + } +} + +@Composable +private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.NavController) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + val pages by vm.pages.collectAsState() + + val executor = remember { Executors.newSingleThreadExecutor() } + val imageCapture = remember { + ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) + .build() + } + var capturing by remember { mutableStateOf(false) } + + val previewView = remember { + PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER } + } + + androidx.compose.runtime.LaunchedEffect(Unit) { + val provider = ProcessCameraProvider.getInstance(context).await() + val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture + ) + } + + Box(Modifier.fillMaxSize()) { + androidx.compose.ui.viewinterop.AndroidView( + factory = { previewView }, + modifier = Modifier.fillMaxSize(), + ) + + // Top bar: settings. + IconButton( + onClick = { navController.navigate(Routes.SETTINGS) }, + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Instellingen", tint = Color.White) + } + + // Bottom controls. + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color(0x66000000)) + .padding(24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Page count. + Box(Modifier.size(56.dp), contentAlignment = Alignment.Center) { + Text("${pages.size}", color = Color.White, style = MaterialTheme.typography.titleLarge) + } + + // Shutter. + FilledIconButton( + onClick = { + if (capturing) return@FilledIconButton + capturing = true + val file = File(vm.capturesDir(), "cap_${System.currentTimeMillis()}.jpg") + val options = ImageCapture.OutputFileOptions.Builder(file).build() + imageCapture.takePicture( + options, + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(result: ImageCapture.OutputFileResults) { + scope.launch { + vm.onImageCaptured(file) + capturing = false + } + } + + override fun onError(exception: ImageCaptureException) { + capturing = false + } + }, + ) + }, + modifier = Modifier.size(76.dp), + shape = CircleShape, + ) { + if (capturing) CircularProgressIndicator(Modifier.size(28.dp), color = Color.White) + } + + // Done -> pages. + IconButton( + onClick = { if (pages.isNotEmpty()) navController.navigate(Routes.PAGES) }, + modifier = Modifier.size(56.dp), + ) { + Icon( + Icons.Filled.Check, + contentDescription = "Klaar", + tint = if (pages.isEmpty()) Color.Gray else Color.White, + ) + } + } + } + + androidx.compose.runtime.DisposableEffect(Unit) { + onDispose { executor.shutdown() } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/Common.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/Common.kt new file mode 100644 index 0000000..018052d --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/Common.kt @@ -0,0 +1,23 @@ +package eu.geyskens.pdfscan.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import eu.geyskens.pdfscan.ScanViewModel +import eu.geyskens.pdfscan.data.Page +import android.graphics.Bitmap + +/** + * Renders [page] to a deskewed, filtered bitmap off the main thread and exposes it as + * Compose state. Re-renders whenever the page's corners, rotation or filter change. + */ +@Composable +fun rememberRendered(vm: ScanViewModel, page: Page): State { + val state = remember(page.id) { mutableStateOf(null) } + LaunchedEffect(page.id, page.corners, page.rotationDegrees, page.filter) { + state.value = vm.render(page) + } + return state +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt new file mode 100644 index 0000000..9d92168 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt @@ -0,0 +1,165 @@ +package eu.geyskens.pdfscan.ui.screens + +import android.graphics.Bitmap +import android.graphics.PointF +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.geyskens.pdfscan.ScanViewModel +import eu.geyskens.pdfscan.data.ScanFilter +import androidx.compose.foundation.Canvas +import androidx.compose.ui.geometry.Offset as GeomOffset + +@Composable +fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String) { + val page = remember(pageId) { vm.page(pageId) } + if (page == null) { + navController.popBackStack() + return + } + + var source by remember(pageId) { mutableStateOf(null) } + androidx.compose.runtime.LaunchedEffect(pageId) { source = vm.loadSource(page) } + + // Local editable copy of the four normalized corners (TL, TR, BR, BL). + val corners = remember(pageId) { + page.corners.map { mutableStateOf(PointF(it.x, it.y)) }.toMutableList() + } + var filter by remember(pageId) { mutableStateOf(page.filter) } + var boxSize by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + + Column(Modifier.fillMaxSize().background(Color.Black)) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .onSizeChanged { boxSize = it }, + contentAlignment = Alignment.Center, + ) { + val bmp = source + if (bmp != null && boxSize != IntSize.Zero) { + val scale = minOf( + boxSize.width.toFloat() / bmp.width, + boxSize.height.toFloat() / bmp.height, + ) + val dispW = bmp.width * scale + val dispH = bmp.height * scale + val offX = (boxSize.width - dispW) / 2f + val offY = (boxSize.height - dispH) / 2f + + fun toScreen(p: PointF) = GeomOffset(offX + p.x * dispW, offY + p.y * dispH) + + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + + // Quad outline. + Canvas(Modifier.fillMaxSize()) { + val pts = corners.map { toScreen(it.value) } + for (i in pts.indices) { + val a = pts[i] + val b = pts[(i + 1) % pts.size] + drawLine(Color(0xFF388BFD), a, b, strokeWidth = 4f) + } + } + + // Draggable handles. + corners.forEachIndexed { i, cornerState -> + val screen = toScreen(cornerState.value) + val handlePx = with(density) { 32.dp.toPx() } + Box( + modifier = Modifier + .offset( + x = with(density) { (screen.x - handlePx / 2).toDp() }, + y = with(density) { (screen.y - handlePx / 2).toDp() }, + ) + .size(32.dp) + .background(Color(0xAA388BFD), CircleShape) + .pointerInput(pageId, i, dispW, dispH) { + detectDragGestures { change, drag -> + change.consume() + val cur = cornerState.value + val nx = (cur.x + drag.x / dispW).coerceIn(0f, 1f) + val ny = (cur.y + drag.y / dispH).coerceIn(0f, 1f) + cornerState.value = PointF(nx, ny) + } + }, + ) + } + } + } + + // Filter chips. + Row( + Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterOption("Kleur", filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR } + FilterOption("Grijs", filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE } + FilterOption("Zwart-wit", filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE } + } + + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton(onClick = { vm.rotate(pageId) }, modifier = Modifier.weight(1f)) { + Text("Draaien") + } + OutlinedButton( + onClick = { + val full = eu.geyskens.pdfscan.data.Page.fullFrameCorners() + full.forEachIndexed { i, p -> corners[i].value = PointF(p.x, p.y) } + }, + modifier = Modifier.weight(1f), + ) { Text("Herstel") } + Button( + onClick = { + vm.updateCorners(pageId, corners.map { PointF(it.value.x, it.value.y) }) + vm.setFilter(pageId, filter) + navController.popBackStack() + }, + modifier = Modifier.weight(1f), + ) { Text("Toepassen") } + } + } +} + +@Composable +private fun FilterOption(label: String, selected: Boolean, onClick: () -> Unit) { + FilterChip(selected = selected, onClick = onClick, label = { Text(label) }) +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt new file mode 100644 index 0000000..5776876 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt @@ -0,0 +1,174 @@ +package eu.geyskens.pdfscan.ui.screens + +import android.content.Intent +import androidx.core.content.FileProvider +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.geyskens.pdfscan.Routes +import eu.geyskens.pdfscan.ScanViewModel +import eu.geyskens.pdfscan.paperless.PaperlessClient +import eu.geyskens.pdfscan.pdf.PageSizeMode +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExportScreen(vm: ScanViewModel, navController: NavController) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val settings by vm.settings.collectAsState() + val pages by vm.pages.collectAsState() + + val defaultName = remember { + "scan_" + SimpleDateFormat("yyyyMMdd_HHmm", Locale.getDefault()).format(Date()) + } + var fileName by remember { mutableStateOf(defaultName) } + var a4 by remember { mutableStateOf(true) } + var busy by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + + fun mode() = if (a4) PageSizeMode.A4 else PageSizeMode.ORIGINAL + + Scaffold( + topBar = { TopAppBar(title = { Text("Exporteren (${pages.size} pagina's)") }) }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = fileName, + onValueChange = { fileName = it }, + label = { Text("Bestandsnaam") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SegmentedButton( + selected = a4, + onClick = { a4 = true }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text("A4-pagina") } + SegmentedButton( + selected = !a4, + onClick = { a4 = false }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { Text("Originele grootte") } + } + + Button( + onClick = { + if (busy) return@Button + busy = true + status = null + scope.launch { + try { + val pdf = vm.exportPdf(fileName, mode()) + val uri = FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", pdf + ) + val share = Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(share, "PDF delen")) + } catch (e: Exception) { + status = "Fout bij exporteren: ${e.message}" + } finally { + busy = false + } + } + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Share, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Delen / opslaan") + } + + Button( + onClick = { + if (busy) return@Button + busy = true + status = null + scope.launch { + try { + val pdf = vm.exportPdf(fileName, mode()) + when (val r = vm.uploadToPaperless(pdf, fileName)) { + is PaperlessClient.Result.Success -> + status = "Geüpload naar Paperless ✓" + is PaperlessClient.Result.Failure -> + status = r.message + } + } catch (e: Exception) { + status = "Fout: ${e.message}" + } finally { + busy = false + } + } + }, + enabled = !busy && settings.paperlessUrl.isNotBlank() && settings.hasToken, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.CloudUpload, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Upload naar Paperless") + } + + if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) { + OutlinedButton( + onClick = { navController.navigate(Routes.SETTINGS) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Paperless instellen") } + } + + if (busy) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.height(24.dp)) + Spacer(Modifier.width(12.dp)) + Text("Bezig…") + } + } + status?.let { Text(it, color = Color(0xFF1F6FEB)) } + } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt new file mode 100644 index 0000000..c472d5b --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt @@ -0,0 +1,145 @@ +package eu.geyskens.pdfscan.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.geyskens.pdfscan.Routes +import eu.geyskens.pdfscan.ScanViewModel +import eu.geyskens.pdfscan.data.Page + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PagesScreen(vm: ScanViewModel, navController: NavController) { + val pages by vm.pages.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Pagina's (${pages.size})") }, + actions = { + IconButton( + onClick = { if (pages.isNotEmpty()) navController.navigate(Routes.EXPORT) }, + ) { + Icon(Icons.Filled.PictureAsPdf, contentDescription = "Exporteer PDF") + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { navController.navigate(Routes.CAMERA) }, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text("Pagina toevoegen") }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 12.dp), + ) { + items(pages, key = { it.id }) { page -> + val index = pages.indexOf(page) + PageRow( + vm = vm, + page = page, + index = index, + isFirst = index == 0, + isLast = index == pages.lastIndex, + onOpen = { navController.navigate(Routes.crop(page.id)) }, + onDelete = { vm.delete(page.id) }, + onUp = { vm.move(index, index - 1) }, + onDown = { vm.move(index, index + 1) }, + ) + } + } + } +} + +@Composable +private fun PageRow( + vm: ScanViewModel, + page: Page, + index: Int, + isFirst: Boolean, + isLast: Boolean, + onOpen: () -> Unit, + onDelete: () -> Unit, + onUp: () -> Unit, + onDown: () -> Unit, +) { + val bitmap by rememberRendered(vm, page) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clickable(onClick = onOpen), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(width = 80.dp, height = 100.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center, + ) { + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "Pagina ${index + 1}", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } + Spacer(Modifier.width(12.dp)) + Text("Pagina ${index + 1}", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Column { + IconButton(onClick = onUp, enabled = !isFirst) { + Icon(Icons.Filled.ArrowUpward, contentDescription = "Omhoog") + } + IconButton(onClick = onDown, enabled = !isLast) { + Icon(Icons.Filled.ArrowDownward, contentDescription = "Omlaag") + } + } + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = "Verwijderen", tint = Color(0xFFD32F2F)) + } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..be5b395 --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt @@ -0,0 +1,155 @@ +package eu.geyskens.pdfscan.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.geyskens.pdfscan.ScanViewModel +import eu.geyskens.pdfscan.data.ScanFilter +import eu.geyskens.pdfscan.paperless.PaperlessClient +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(vm: ScanViewModel, navController: NavController) { + val scope = rememberCoroutineScope() + val settings by vm.settings.collectAsState() + + var url by remember { mutableStateOf(settings.paperlessUrl) } + var token by remember { mutableStateOf(vm.currentToken().orEmpty()) } + var testing by remember { mutableStateOf(false) } + var testResult by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Instellingen") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Terug") + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Paperless-ngx", style = androidx.compose.material3.MaterialTheme.typography.titleMedium) + + OutlinedTextField( + value = url, + onValueChange = { url = it; testResult = null }, + label = { Text("Server-URL (bv. https://paperless.thuis.lan)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = token, + onValueChange = { token = it; testResult = null }, + label = { Text("API-token") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + if (url.startsWith("http://")) { + Text( + "⚠ Je gebruikt http:// — je token en documenten gaan onversleuteld over het " + + "netwerk. Gebruik https:// als het even kan.", + color = Color(0xFFB26A00), + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + testing = true + testResult = null + scope.launch { + testResult = when (val r = vm.testPaperless(url, token)) { + is PaperlessClient.Result.Success -> "Verbinding OK ✓" + is PaperlessClient.Result.Failure -> r.message + } + testing = false + } + }, + enabled = !testing, + modifier = Modifier.weight(1f), + ) { Text("Verbinding testen") } + + Button( + onClick = { scope.launch { vm.savePaperless(url, token) } }, + modifier = Modifier.weight(1f), + ) { Text("Opslaan") } + } + if (testing) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.width(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("Testen…") + } + } + testResult?.let { Text(it, color = Color(0xFF1F6FEB)) } + + Text( + "Standaardfilter", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = settings.defaultFilter == ScanFilter.COLOR, + onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.COLOR) } }, + label = { Text("Kleur") }, + ) + FilterChip( + selected = settings.defaultFilter == ScanFilter.GRAYSCALE, + onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.GRAYSCALE) } }, + label = { Text("Grijs") }, + ) + FilterChip( + selected = settings.defaultFilter == ScanFilter.BLACK_WHITE, + onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.BLACK_WHITE) } }, + label = { Text("Zwart-wit") }, + ) + } + } + } +} diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/theme/Theme.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/theme/Theme.kt new file mode 100644 index 0000000..fc63c0c --- /dev/null +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/theme/Theme.kt @@ -0,0 +1,25 @@ +package eu.geyskens.pdfscan.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val Blue = Color(0xFF1F6FEB) +private val BlueDark = Color(0xFF388BFD) + +private val LightColors = lightColorScheme(primary = Blue, secondary = Blue) +private val DarkColors = darkColorScheme(primary = BlueDark, secondary = BlueDark) + +@Composable +fun PaperScanTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + content = content, + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..28de13b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..015f013 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1F6FEB + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..007b46d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + PaperScan + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..0eda8e2 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..d99f2fe --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8cc52bd --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file. Plugin versions declared here, applied per-module. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..d7fcbcc --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..59d4317 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,50 @@ +[versions] +agp = "8.7.3" +kotlin = "2.1.0" +coreKtx = "1.15.0" +lifecycle = "2.8.7" +activityCompose = "1.9.3" +composeBom = "2024.12.01" +navigationCompose = "2.8.5" +camerax = "1.4.1" +opencv = "4.11.0" +okhttp = "4.12.0" +datastore = "1.1.1" +securityCrypto = "1.1.0-alpha06" +accompanistPermissions = "0.36.0" +exifinterface = "1.3.7" +concurrentFutures = "1.2.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } + +opencv = { group = "org.opencv", name = "opencv", version.ref = "opencv" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } +androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } +androidx-concurrent-futures-ktx = { group = "androidx.concurrent", name = "concurrent-futures-ktx", version.ref = "concurrentFutures" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/keystore.properties.example b/keystore.properties.example new file mode 100644 index 0000000..1e01144 --- /dev/null +++ b/keystore.properties.example @@ -0,0 +1,5 @@ +# Kopieer naar keystore.properties (staat in .gitignore) voor lokale release-builds. +storeFile=/absoluut/pad/naar/release.jks +storePassword=CHANGEME +keyAlias=paperscan +keyPassword=CHANGEME diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..5ece8cc --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "PaperScan" +include(":app")