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 <noreply@anthropic.com>
This commit is contained in:
110
app/build.gradle.kts
Normal file
110
app/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
8
app/proguard-rules.pro
vendored
Normal file
8
app/proguard-rules.pro
vendored
Normal file
@@ -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.** { *; }
|
||||
42
app/src/main/AndroidManifest.xml
Normal file
42
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Only used to reach a self-hosted Paperless-ngx instance the user configures. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".PaperScanApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PaperScan"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.PaperScan">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
62
app/src/main/java/eu/geyskens/pdfscan/MainActivity.kt
Normal file
62
app/src/main/java/eu/geyskens/pdfscan/MainActivity.kt
Normal file
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/src/main/java/eu/geyskens/pdfscan/PaperScanApp.kt
Normal file
15
app/src/main/java/eu/geyskens/pdfscan/PaperScanApp.kt
Normal file
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
147
app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt
Normal file
147
app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt
Normal file
@@ -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<AppSettings> =
|
||||
settingsRepo.settings.stateIn(viewModelScope, SharingStarted.Eagerly, AppSettings())
|
||||
|
||||
private val _pages = MutableStateFlow<List<Page>>(emptyList())
|
||||
val pages: StateFlow<List<Page>> = _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<String, Pair<Int, Bitmap>>()
|
||||
|
||||
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<PointF>) = 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()
|
||||
}
|
||||
}
|
||||
34
app/src/main/java/eu/geyskens/pdfscan/data/Page.kt
Normal file
34
app/src/main/java/eu/geyskens/pdfscan/data/Page.kt
Normal file
@@ -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<PointF>,
|
||||
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<PointF> = listOf(
|
||||
PointF(0f, 0f),
|
||||
PointF(1f, 0f),
|
||||
PointF(1f, 1f),
|
||||
PointF(0f, 1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Preferences> 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<AppSettings> = 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 }
|
||||
}
|
||||
}
|
||||
@@ -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.") }
|
||||
}
|
||||
}
|
||||
60
app/src/main/java/eu/geyskens/pdfscan/pdf/PdfBuilder.kt
Normal file
60
app/src/main/java/eu/geyskens/pdfscan/pdf/PdfBuilder.kt
Normal file
@@ -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<Bitmap>, 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)
|
||||
}
|
||||
}
|
||||
45
app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt
Normal file
45
app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
81
app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt
Normal file
81
app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt
Normal file
@@ -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<PointF>? {
|
||||
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<MatOfPoint>()
|
||||
Imgproc.findContours(
|
||||
edges, contours, Mat(),
|
||||
Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
|
||||
val frameArea = work.rows() * work.cols()
|
||||
var best: List<PointF>? = 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<PointF>): List<PointF> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
105
app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt
Normal file
105
app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt
Normal file
@@ -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<PointF>,
|
||||
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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
188
app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt
Normal file
188
app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt
Normal file
@@ -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() }
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/eu/geyskens/pdfscan/ui/screens/Common.kt
Normal file
23
app/src/main/java/eu/geyskens/pdfscan/ui/screens/Common.kt
Normal file
@@ -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<Bitmap?> {
|
||||
val state = remember(page.id) { mutableStateOf<Bitmap?>(null) }
|
||||
LaunchedEffect(page.id, page.corners, page.rotationDegrees, page.filter) {
|
||||
state.value = vm.render(page)
|
||||
}
|
||||
return state
|
||||
}
|
||||
165
app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt
Normal file
165
app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt
Normal file
@@ -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<Bitmap?>(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) })
|
||||
}
|
||||
174
app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt
Normal file
174
app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt
Normal file
@@ -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<String?>(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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
145
app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt
Normal file
145
app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(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") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/eu/geyskens/pdfscan/ui/theme/Theme.kt
Normal file
25
app/src/main/java/eu/geyskens/pdfscan/ui/theme/Theme.kt
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Simple document-with-corners glyph, centered in the adaptive safe zone. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M38,34h32a4,4 0 0 1 4,4v32a4,4 0 0 1 -4,4h-32a4,4 0 0 1 -4,-4v-32a4,4 0 0 1 4,-4zM40,40v6h4v-2h2v-4zM68,40h-6v4h2v2h4zM40,68h6v-4h-2v-2h-4zM68,68v-6h-4v2h-2v4z" />
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1F6FEB</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">PaperScan</string>
|
||||
</resources>
|
||||
7
app/src/main/res/values/themes.xml
Normal file
7
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.PaperScan" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/black</item>
|
||||
<item name="android:navigationBarColor">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
5
app/src/main/res/xml/file_paths.xml
Normal file
5
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- Exported PDFs live in the app cache and are shared via FileProvider. -->
|
||||
<cache-path name="shared_pdfs" path="exports/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user