1 Commits

Author SHA1 Message Date
sam
752d4972bb Capture feedback (shutter sound + flash) and Paperless UX; v0.1.1
All checks were successful
Build APK / build (push) Successful in 5m40s
- Camera: system shutter sound (only when ringer isn't silenced) + brief
  white flash overlay on capture, for clear feedback even on silent.
- Settings: snackbar confirmation when saving.
- Export: explain why the Paperless upload button is disabled when unconfigured.
- Bump versionCode 2 / versionName 0.1.1.

Fixes #1, #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:49:10 +02:00
4 changed files with 48 additions and 3 deletions

View File

@@ -24,8 +24,8 @@ android {
applicationId = "eu.geyskens.pdfscan" applicationId = "eu.geyskens.pdfscan"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 2
versionName = "0.1.0" versionName = "0.1.1"
// OpenCV native libs are large; ship only the ABIs real phones use. // OpenCV native libs are large; ship only the ABIs real phones use.
ndk { ndk {

View File

@@ -1,12 +1,16 @@
package eu.geyskens.pdfscan.ui.screens package eu.geyskens.pdfscan.ui.screens
import android.Manifest import android.Manifest
import android.media.AudioManager
import android.media.MediaActionSound
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -31,6 +35,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -98,6 +103,22 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
} }
var capturing by remember { mutableStateOf(false) } var capturing by remember { mutableStateOf(false) }
// Capture feedback: system shutter sound + a brief white flash over the preview,
// so there's clear confirmation even when the phone is on silent.
val shutterSound = remember { MediaActionSound().apply { load(MediaActionSound.SHUTTER_CLICK) } }
val audioManager = remember { ContextCompat.getSystemService(context, AudioManager::class.java) }
val flash = remember { Animatable(0f) }
var captureTick by remember { mutableIntStateOf(0) }
androidx.compose.runtime.LaunchedEffect(captureTick) {
if (captureTick > 0) {
flash.snapTo(0.75f)
flash.animateTo(0f, tween(320))
}
}
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { shutterSound.release() }
}
val previewView = remember { val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER } PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
} }
@@ -146,6 +167,11 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
onClick = { onClick = {
if (capturing) return@FilledIconButton if (capturing) return@FilledIconButton
capturing = true capturing = true
// Flash always; play the shutter sound only when the ringer isn't silenced.
if (audioManager?.ringerMode == AudioManager.RINGER_MODE_NORMAL) {
shutterSound.play(MediaActionSound.SHUTTER_CLICK)
}
captureTick++
val file = File(vm.capturesDir(), "cap_${System.currentTimeMillis()}.jpg") val file = File(vm.capturesDir(), "cap_${System.currentTimeMillis()}.jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build() val options = ImageCapture.OutputFileOptions.Builder(file).build()
imageCapture.takePicture( imageCapture.takePicture(
@@ -183,6 +209,11 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
) )
} }
} }
// Shutter flash overlay (drawn on top, fades out quickly).
if (flash.value > 0f) {
Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = flash.value)))
}
} }
androidx.compose.runtime.DisposableEffect(Unit) { androidx.compose.runtime.DisposableEffect(Unit) {

View File

@@ -155,6 +155,11 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
} }
if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) { if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) {
Text(
"Configureer eerst Paperless (server-URL + token) in de instellingen om te kunnen uploaden.",
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color = Color(0xFF9A9A9A),
)
OutlinedButton( OutlinedButton(
onClick = { navController.navigate(Routes.SETTINGS) }, onClick = { navController.navigate(Routes.SETTINGS) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View File

@@ -21,6 +21,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -51,8 +53,10 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
var token by remember { mutableStateOf(vm.currentToken().orEmpty()) } var token by remember { mutableStateOf(vm.currentToken().orEmpty()) }
var testing by remember { mutableStateOf(false) } var testing by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<String?>(null) } var testResult by remember { mutableStateOf<String?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Instellingen") }, title = { Text("Instellingen") },
@@ -116,7 +120,12 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
) { Text("Verbinding testen") } ) { Text("Verbinding testen") }
Button( Button(
onClick = { scope.launch { vm.savePaperless(url, token) } }, onClick = {
scope.launch {
vm.savePaperless(url, token)
snackbarHostState.showSnackbar("Instellingen opgeslagen ✓")
}
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Opslaan") } ) { Text("Opslaan") }
} }