Compare commits

...

6 commits

Author SHA1 Message Date
efe406f0b0 Refactor CameraScreen to use ViewModel with full UI improvements
Migrate all UI state from local remember{} to CameraViewModel for
surviving configuration changes. Add processing overlay indicator,
accessibility semantics on interactive elements, gallery preview
with Cancel/Apply flow, and consistent bottom bar layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:23:43 +01:00
ee2b11941a Handle permanently denied camera permission with Settings redirect
Detect when camera permission is permanently denied (not granted and
rationale not shown) and offer an "Open Settings" button instead of
a non-functional "Grant" button. Use centralized AppColors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:23:34 +01:00
527c8fd0bb Add accessibility semantics and touch targets to zoom and lens controls
Increase ZoomButton to 48dp minimum touch target, add Role.Button and
contentDescription/selected semantics, and use centralized AppColors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:15:26 +01:00
04b61fdd9e Move capture callbacks to background executor
Replace ContextCompat.getMainExecutor with a dedicated single-thread
executor for image capture callbacks. This prevents bitmap decode,
rotation, and tilt-shift effect processing from blocking the UI thread
and causing jank or ANR on slower devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:08:00 +01:00
72156f4e5d Add dark outlines to overlay guides and improve gesture zones
- Draw all guide lines with a dark outline behind the accent color
  to ensure visibility over bright/amber camera scenes
- Clamp gesture zone focus size to MIN_FOCUS_SIZE_PX (150px) so
  rotation zone remains usable at small focus sizes
- Add semantics contentDescription to Canvas for TalkBack
- Use AppColors for centralized color references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:07:50 +01:00
6a1d66bd4b Add ViewModel for state preservation and centralize color constants
- Create CameraViewModel with AndroidViewModel to survive configuration
  changes (rotation). All blur params, capture state, gallery preview,
  and thumbnail state now live in StateFlow fields
- Create AppColors object to centralize the 12+ hardcoded color literals
  into a single source of truth
- Add lifecycle-viewmodel-compose dependency

Fixes: state lost on rotation, orphaned capture coroutines on config
change, accent color maintenance risk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:07:39 +01:00
10 changed files with 650 additions and 263 deletions

View file

@ -80,6 +80,7 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
// Compose // Compose

View file

@ -1,7 +1,10 @@
package no.naiv.tiltshift package no.naiv.tiltshift
import android.Manifest import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -25,6 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -38,7 +42,9 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import no.naiv.tiltshift.ui.CameraScreen import no.naiv.tiltshift.ui.CameraScreen
import no.naiv.tiltshift.ui.theme.AppColors
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -96,12 +102,17 @@ private fun TiltShiftApp() {
} }
} }
else -> { else -> {
// Permanently denied: not granted AND rationale not shown
val cameraPermanentlyDenied = !cameraPermission.status.isGranted &&
!cameraPermission.status.shouldShowRationale
// Show permission request UI // Show permission request UI
PermissionRequestScreen( PermissionRequestScreen(
onRequestCamera = { cameraPermission.launchPermissionRequest() }, onRequestCamera = { cameraPermission.launchPermissionRequest() },
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() }, onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
cameraGranted = cameraPermission.status.isGranted, cameraGranted = cameraPermission.status.isGranted,
locationGranted = locationPermissions.allPermissionsGranted locationGranted = locationPermissions.allPermissionsGranted,
cameraPermanentlyDenied = cameraPermanentlyDenied
) )
} }
} }
@ -113,7 +124,8 @@ private fun PermissionRequestScreen(
onRequestCamera: () -> Unit, onRequestCamera: () -> Unit,
onRequestLocation: () -> Unit, onRequestLocation: () -> Unit,
cameraGranted: Boolean, cameraGranted: Boolean,
locationGranted: Boolean locationGranted: Boolean,
cameraPermanentlyDenied: Boolean
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -146,6 +158,7 @@ private fun PermissionRequestScreen(
title = "Camera", title = "Camera",
description = "Required to take photos", description = "Required to take photos",
isGranted = cameraGranted, isGranted = cameraGranted,
isPermanentlyDenied = cameraPermanentlyDenied,
onRequest = onRequestCamera onRequest = onRequestCamera
) )
@ -168,8 +181,10 @@ private fun PermissionItem(
title: String, title: String,
description: String, description: String,
isGranted: Boolean, isGranted: Boolean,
isPermanentlyDenied: Boolean = false,
onRequest: () -> Unit onRequest: () -> Unit
) { ) {
val context = LocalContext.current
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
@ -182,7 +197,7 @@ private fun PermissionItem(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = if (isGranted) Color(0xFF4CAF50) else Color(0xFFFFB300), tint = if (isGranted) AppColors.Success else AppColors.Accent,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
@ -201,10 +216,27 @@ private fun PermissionItem(
if (!isGranted) { if (!isGranted) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
if (isPermanentlyDenied) {
Button(
onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
},
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Accent
)
) {
Text("Open Settings", color = Color.Black)
}
} else {
Button( Button(
onClick = onRequest, onClick = onRequest,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFFB300) containerColor = AppColors.Accent
) )
) { ) {
Text("Grant", color = Color.Black) Text("Grant", color = Color.Black)
@ -212,4 +244,5 @@ private fun PermissionItem(
} }
} }
} }
}
} }

View file

@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.Executor import java.util.concurrent.Executor
import java.util.concurrent.Executors
/** /**
* Manages CameraX camera setup and controls. * Manages CameraX camera setup and controls.
@ -56,6 +57,9 @@ class CameraManager(private val context: Context) {
private val _isFrontCamera = MutableStateFlow(false) private val _isFrontCamera = MutableStateFlow(false)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow() val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
/** Background executor for image capture callbacks to avoid blocking the main thread. */
private val captureExecutor: Executor = Executors.newSingleThreadExecutor()
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
private var surfaceSize: Size = Size(1920, 1080) private var surfaceSize: Size = Size(1920, 1080)
private var lifecycleOwnerRef: LifecycleOwner? = null private var lifecycleOwnerRef: LifecycleOwner? = null
@ -194,10 +198,12 @@ class CameraManager(private val context: Context) {
} }
/** /**
* Gets the executor for image capture callbacks. * Gets the background executor for image capture callbacks.
* Uses a dedicated thread to avoid blocking the main/UI thread during heavy
* bitmap processing (decode, rotate, tilt-shift effect).
*/ */
fun getExecutor(): Executor { fun getExecutor(): Executor {
return ContextCompat.getMainExecutor(context) return captureExecutor
} }
/** /**

View file

@ -3,8 +3,6 @@ package no.naiv.tiltshift.ui
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.location.Location
import android.net.Uri
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
@ -34,11 +32,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipCameraAndroid import androidx.compose.material.icons.filled.FlipCameraAndroid
import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@ -51,124 +53,96 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.Image
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.delay import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import no.naiv.tiltshift.camera.CameraManager
import no.naiv.tiltshift.camera.ImageCaptureHandler
import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer import no.naiv.tiltshift.effect.TiltShiftRenderer
import no.naiv.tiltshift.storage.PhotoSaver import no.naiv.tiltshift.ui.theme.AppColors
import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.HapticFeedback
import no.naiv.tiltshift.util.LocationProvider
import no.naiv.tiltshift.util.OrientationDetector
/** /**
* Main camera screen with tilt-shift controls. * Main camera screen with tilt-shift controls.
* Uses CameraViewModel to survive configuration changes.
*/ */
@Composable @Composable
fun CameraScreen( fun CameraScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier,
viewModel: CameraViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
// Camera and effect state // GL state (view-layer, not in ViewModel)
val cameraManager = remember { CameraManager(context) }
val photoSaver = remember { PhotoSaver(context) }
val captureHandler = remember { ImageCaptureHandler(context, photoSaver) }
val haptics = remember { HapticFeedback(context) }
val orientationDetector = remember { OrientationDetector(context) }
val locationProvider = remember { LocationProvider(context) }
// State
var blurParams by remember { mutableStateOf(BlurParameters.DEFAULT) }
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) } var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) } var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) } var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
var isCapturing by remember { mutableStateOf(false) } // Collect ViewModel state
var showSaveSuccess by remember { mutableStateOf(false) } val blurParams by viewModel.blurParams.collectAsState()
var showSaveError by remember { mutableStateOf<String?>(null) } val isCapturing by viewModel.isCapturing.collectAsState()
var showControls by remember { mutableStateOf(false) } val isProcessing by viewModel.isProcessing.collectAsState()
val showSaveSuccess by viewModel.showSaveSuccess.collectAsState()
// Thumbnail state for last captured photo val showSaveError by viewModel.showSaveError.collectAsState()
var lastSavedUri by remember { mutableStateOf<Uri?>(null) } val showControls by viewModel.showControls.collectAsState()
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) } val lastSavedUri by viewModel.lastSavedUri.collectAsState()
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
// Gallery preview mode: non-null means we're previewing a gallery image val galleryBitmap by viewModel.galleryBitmap.collectAsState()
var galleryBitmap by remember { mutableStateOf<Bitmap?>(null) }
var galleryImageUri by remember { mutableStateOf<Uri?>(null) }
val isGalleryPreview = galleryBitmap != null val isGalleryPreview = galleryBitmap != null
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState()
var currentLocation by remember { mutableStateOf<Location?>(null) } val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
val cameraError by viewModel.cameraManager.error.collectAsState()
// Gallery picker: load image for interactive preview before processing // Gallery picker
val galleryLauncher = rememberLauncherForActivityResult( val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia() contract = ActivityResultContracts.PickVisualMedia()
) { uri -> ) { uri ->
if (uri != null && !isCapturing && !isGalleryPreview) { if (uri != null) {
scope.launch { viewModel.loadGalleryImage(uri)
val bitmap = captureHandler.loadGalleryImage(uri)
if (bitmap != null) {
galleryBitmap = bitmap
galleryImageUri = uri
} else {
haptics.error()
showSaveError = "Failed to load image"
delay(2000)
showSaveError = null
}
}
} }
} }
val zoomRatio by cameraManager.zoomRatio.collectAsState() // Show camera errors
val minZoom by cameraManager.minZoomRatio.collectAsState()
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
val cameraError by cameraManager.error.collectAsState()
// Show camera errors via the existing error UI
LaunchedEffect(cameraError) { LaunchedEffect(cameraError) {
cameraError?.let { message -> cameraError?.let { message ->
showSaveError = message viewModel.showCameraError(message)
cameraManager.clearError() viewModel.cameraManager.clearError()
delay(2000)
showSaveError = null
} }
} }
// Collect orientation updates // Collect orientation updates
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
orientationDetector.orientationFlow().collectLatest { rotation -> viewModel.orientationDetector.orientationFlow().collectLatest { rotation ->
currentRotation = rotation viewModel.updateRotation(rotation)
} }
} }
// Collect location updates // Collect location updates
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
locationProvider.locationFlow().collectLatest { location -> viewModel.locationProvider.locationFlow().collectLatest { location ->
currentLocation = location viewModel.updateLocation(location)
} }
} }
@ -187,17 +161,14 @@ fun CameraScreen(
// Start camera when surface texture is available // Start camera when surface texture is available
LaunchedEffect(surfaceTexture) { LaunchedEffect(surfaceTexture) {
surfaceTexture?.let { surfaceTexture?.let {
cameraManager.startCamera(lifecycleOwner) { surfaceTexture } viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
} }
} }
// Cleanup // Cleanup GL resources (ViewModel handles its own cleanup in onCleared)
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
cameraManager.release()
renderer?.release() renderer?.release()
lastThumbnailBitmap?.recycle()
galleryBitmap?.recycle()
} }
} }
@ -211,7 +182,7 @@ fun CameraScreen(
galleryBitmap?.let { bmp -> galleryBitmap?.let { bmp ->
Image( Image(
bitmap = bmp.asImageBitmap(), bitmap = bmp.asImageBitmap(),
contentDescription = "Gallery preview", contentDescription = "Gallery image preview. Adjust tilt-shift parameters then tap Apply.",
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -244,13 +215,12 @@ fun CameraScreen(
TiltShiftOverlay( TiltShiftOverlay(
params = blurParams, params = blurParams,
onParamsChange = { newParams -> onParamsChange = { newParams ->
blurParams = newParams viewModel.updateBlurParams(newParams)
haptics.tick()
}, },
onZoomChange = { zoomDelta -> onZoomChange = { zoomDelta ->
if (!isGalleryPreview) { if (!isGalleryPreview) {
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
cameraManager.setZoom(newZoom) viewModel.cameraManager.setZoom(newZoom)
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@ -269,7 +239,6 @@ fun CameraScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (!isGalleryPreview) { if (!isGalleryPreview) {
// Zoom indicator
ZoomIndicator(currentZoom = zoomRatio) ZoomIndicator(currentZoom = zoomRatio)
} else { } else {
Spacer(modifier = Modifier.width(1.dp)) Spacer(modifier = Modifier.width(1.dp))
@ -280,29 +249,32 @@ fun CameraScreen(
// Camera flip button // Camera flip button
IconButton( IconButton(
onClick = { onClick = {
cameraManager.switchCamera() viewModel.cameraManager.switchCamera()
haptics.click() viewModel.haptics.click()
} }
) { ) {
Icon( Icon(
imageVector = Icons.Default.FlipCameraAndroid, imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch Camera", contentDescription = "Switch between front and back camera",
tint = Color.White tint = Color.White
) )
} }
} }
// Toggle controls button // Toggle controls button (tune icon instead of cryptic "Ctrl")
IconButton( IconButton(
onClick = { onClick = {
showControls = !showControls viewModel.toggleControls()
haptics.tick() viewModel.haptics.tick()
},
modifier = Modifier.semantics {
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
} }
) { ) {
Text( Icon(
text = if (showControls) "Hide" else "Ctrl", imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune,
color = Color.White, contentDescription = if (showControls) "Hide blur controls" else "Show blur controls",
fontSize = 12.sp tint = Color.White
) )
} }
} }
@ -318,8 +290,8 @@ fun CameraScreen(
ModeToggle( ModeToggle(
currentMode = blurParams.mode, currentMode = blurParams.mode,
onModeChange = { mode -> onModeChange = { mode ->
blurParams = blurParams.copy(mode = mode) viewModel.updateBlurParams(blurParams.copy(mode = mode))
haptics.click() viewModel.haptics.click()
} }
) )
} }
@ -337,8 +309,9 @@ fun CameraScreen(
ControlPanel( ControlPanel(
params = blurParams, params = blurParams,
onParamsChange = { newParams -> onParamsChange = { newParams ->
blurParams = newParams viewModel.updateBlurParams(newParams)
} },
onReset = { viewModel.resetBlurParams() }
) )
} }
@ -352,108 +325,93 @@ fun CameraScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (isGalleryPreview) { if (isGalleryPreview) {
// Gallery preview mode: Cancel | Apply // Gallery preview mode: Cancel | Apply (matched layout to camera mode)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(48.dp) horizontalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
// Cancel button // Cancel button (same 52dp as gallery button for layout consistency)
IconButton( IconButton(
onClick = { onClick = { viewModel.cancelGalleryPreview() },
val oldBitmap = galleryBitmap
galleryBitmap = null
galleryImageUri = null
oldBitmap?.recycle()
},
modifier = Modifier modifier = Modifier
.size(56.dp) .size(52.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0x80000000)) .background(AppColors.OverlayDark)
.semantics { contentDescription = "Cancel gallery import" }
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Cancel", contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
} }
// Apply button // Apply button (same 72dp as capture button for layout consistency)
IconButton( Box(
onClick = {
val uri = galleryImageUri ?: return@IconButton
if (!isCapturing) {
isCapturing = true
haptics.heavyClick()
scope.launch {
val result = captureHandler.processExistingImage(
imageUri = uri,
blurParams = blurParams,
location = currentLocation
)
when (result) {
is SaveResult.Success -> {
haptics.success()
val oldThumb = lastThumbnailBitmap
lastThumbnailBitmap = result.thumbnail
lastSavedUri = result.uri
oldThumb?.recycle()
showSaveSuccess = true
delay(1500)
showSaveSuccess = false
}
is SaveResult.Error -> {
haptics.error()
showSaveError = result.message
delay(2000)
showSaveError = null
}
}
val oldGalleryBitmap = galleryBitmap
galleryBitmap = null
galleryImageUri = null
oldGalleryBitmap?.recycle()
isCapturing = false
}
}
},
enabled = !isCapturing,
modifier = Modifier modifier = Modifier
.size(56.dp) .size(72.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFFFFB300)) .border(4.dp, Color.White, CircleShape)
.clickable(
enabled = !isCapturing,
role = Role.Button,
onClick = { viewModel.applyGalleryEffect() }
)
.semantics {
contentDescription = "Apply tilt-shift effect to gallery image"
if (isCapturing) stateDescription = "Processing"
},
contentAlignment = Alignment.Center
) { ) {
Box(
modifier = Modifier
.size(if (isCapturing) 48.dp else 60.dp)
.clip(CircleShape)
.background(if (isCapturing) AppColors.Accent.copy(alpha = 0.5f) else AppColors.Accent),
contentAlignment = Alignment.Center
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.Black,
strokeWidth = 3.dp
)
} else {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = "Apply effect", contentDescription = null,
tint = Color.Black, tint = Color.Black,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
} }
} }
}
// Spacer for visual symmetry
Spacer(modifier = Modifier.size(52.dp))
}
} else { } else {
// Camera mode: Zoom presets + Gallery | Capture | Spacer // Camera mode: Zoom presets + Gallery | Capture | Spacer
// Zoom presets (only show for back camera)
if (!isFrontCamera) { if (!isFrontCamera) {
ZoomControl( ZoomControl(
currentZoom = zoomRatio, currentZoom = zoomRatio,
minZoom = minZoom, minZoom = minZoom,
maxZoom = maxZoom, maxZoom = maxZoom,
onZoomSelected = { zoom -> onZoomSelected = { zoom ->
cameraManager.setZoom(zoom) viewModel.cameraManager.setZoom(zoom)
haptics.click() viewModel.haptics.click()
} }
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
// Gallery button | Capture button | Spacer for symmetry
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp) horizontalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
// Gallery picker button // Gallery picker button (with background for discoverability)
IconButton( IconButton(
onClick = { onClick = {
if (!isCapturing) { if (!isCapturing) {
@ -463,11 +421,14 @@ fun CameraScreen(
} }
}, },
enabled = !isCapturing, enabled = !isCapturing,
modifier = Modifier.size(52.dp) modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
) { ) {
Icon( Icon(
imageVector = Icons.Default.PhotoLibrary, imageVector = Icons.Default.PhotoLibrary,
contentDescription = "Pick from gallery", contentDescription = "Pick image from gallery",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
@ -476,46 +437,8 @@ fun CameraScreen(
// Capture button // Capture button
CaptureButton( CaptureButton(
isCapturing = isCapturing, isCapturing = isCapturing,
onClick = { isProcessing = isProcessing,
if (!isCapturing) { onClick = { viewModel.capturePhoto() }
isCapturing = true
haptics.heavyClick()
scope.launch {
val imageCapture = cameraManager.imageCapture
if (imageCapture != null) {
val result = captureHandler.capturePhoto(
imageCapture = imageCapture,
executor = cameraManager.getExecutor(),
blurParams = blurParams,
deviceRotation = currentRotation,
location = currentLocation,
isFrontCamera = isFrontCamera
)
when (result) {
is SaveResult.Success -> {
haptics.success()
val oldThumb = lastThumbnailBitmap
lastThumbnailBitmap = result.thumbnail
lastSavedUri = result.uri
oldThumb?.recycle()
showSaveSuccess = true
delay(1500)
showSaveSuccess = false
}
is SaveResult.Error -> {
haptics.error()
showSaveError = result.message
delay(2000)
showSaveError = null
}
}
}
isCapturing = false
}
}
}
) )
// Spacer for visual symmetry with gallery button // Spacer for visual symmetry with gallery button
@ -546,30 +469,32 @@ fun CameraScreen(
.padding(bottom = 48.dp, end = 16.dp) .padding(bottom = 48.dp, end = 16.dp)
) )
// Success indicator // Success indicator (announced to accessibility)
AnimatedVisibility( AnimatedVisibility(
visible = showSaveSuccess, visible = showSaveSuccess,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier.align(Alignment.Center) modifier = Modifier
.align(Alignment.Center)
.semantics { liveRegion = LiveRegionMode.Polite }
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(80.dp) .size(80.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF4CAF50)), .background(AppColors.Success),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = "Saved", contentDescription = "Photo saved successfully",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) )
} }
} }
// Error indicator // Error indicator (announced to accessibility)
AnimatedVisibility( AnimatedVisibility(
visible = showSaveError != null, visible = showSaveError != null,
enter = fadeIn(), enter = fadeIn(),
@ -577,17 +502,18 @@ fun CameraScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.padding(32.dp) .padding(32.dp)
.semantics { liveRegion = LiveRegionMode.Assertive }
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(Color(0xFFF44336)) .background(AppColors.Error)
.padding(24.dp) .padding(24.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Error", contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
@ -595,10 +521,35 @@ fun CameraScreen(
Text( Text(
text = showSaveError ?: "Error", text = showSaveError ?: "Error",
color = Color.White, color = Color.White,
fontSize = 14.sp fontSize = 14.sp,
maxLines = 3
) )
} }
} }
// Processing overlay
AnimatedVisibility(
visible = isProcessing,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
if (!showSaveSuccess) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = AppColors.Accent,
strokeWidth = 3.dp,
modifier = Modifier.size(36.dp)
)
}
}
}
} }
} }
@ -614,7 +565,7 @@ private fun ModeToggle(
Row( Row(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(Color(0x80000000)) .background(AppColors.OverlayDark)
.padding(4.dp), .padding(4.dp),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@ -641,9 +592,13 @@ private fun ModeButton(
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(if (isSelected) Color(0xFFFFB300) else Color.Transparent) .background(if (isSelected) AppColors.Accent else Color.Transparent)
.clickable(onClick = onClick) .clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 12.dp)
.semantics {
stateDescription = if (isSelected) "Selected" else "Not selected"
contentDescription = "$text blur mode"
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@ -656,14 +611,15 @@ private fun ModeButton(
/** /**
* Control panel with sliders for blur parameters. * Control panel with sliders for blur parameters.
* Includes position/size/angle sliders as gesture alternatives for accessibility.
*/ */
@Composable @Composable
private fun ControlPanel( private fun ControlPanel(
params: BlurParameters, params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit, onParamsChange: (BlurParameters) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// Use rememberUpdatedState to avoid stale closure capture during slider drags
val currentParams by rememberUpdatedState(params) val currentParams by rememberUpdatedState(params)
val currentOnParamsChange by rememberUpdatedState(onParamsChange) val currentOnParamsChange by rememberUpdatedState(onParamsChange)
@ -671,15 +627,34 @@ private fun ControlPanel(
modifier = modifier modifier = modifier
.width(200.dp) .width(200.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(Color(0xCC000000)) .background(AppColors.OverlayDarker)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Reset button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onReset,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = "Reset all parameters to defaults",
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
}
// Blur intensity slider // Blur intensity slider
SliderControl( SliderControl(
label = "Blur", label = "Blur",
value = params.blurAmount, value = params.blurAmount,
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR, valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) } onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
) )
@ -688,19 +663,38 @@ private fun ControlPanel(
label = "Falloff", label = "Falloff",
value = params.falloff, value = params.falloff,
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF, valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) } onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
) )
// Size slider (gesture alternative for pinch-to-resize)
SliderControl(
label = "Size",
value = params.size,
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
)
// Aspect ratio slider (radial mode only) // Aspect ratio slider (radial mode only)
if (params.mode == BlurMode.RADIAL) { if (params.mode == BlurMode.RADIAL) {
SliderControl( SliderControl(
label = "Shape", label = "Shape",
value = params.aspectRatio, value = params.aspectRatio,
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT, valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
formatValue = { "%.1f:1".format(it) },
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
) )
} }
// Angle slider (gesture alternative for two-finger rotate)
SliderControl(
label = "Angle",
value = params.angle,
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
)
} }
} }
@ -709,6 +703,7 @@ private fun SliderControl(
label: String, label: String,
value: Float, value: Float,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
onValueChange: (Float) -> Unit onValueChange: (Float) -> Unit
) { ) {
Column { Column {
@ -722,7 +717,7 @@ private fun SliderControl(
fontSize = 12.sp fontSize = 12.sp
) )
Text( Text(
text = "${(value * 100).toInt()}%", text = formatValue(value),
color = Color.White.copy(alpha = 0.7f), color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp fontSize = 12.sp
) )
@ -732,21 +727,24 @@ private fun SliderControl(
onValueChange = onValueChange, onValueChange = onValueChange,
valueRange = valueRange, valueRange = valueRange,
colors = SliderDefaults.colors( colors = SliderDefaults.colors(
thumbColor = Color(0xFFFFB300), thumbColor = AppColors.Accent,
activeTrackColor = Color(0xFFFFB300), activeTrackColor = AppColors.Accent,
inactiveTrackColor = Color.White.copy(alpha = 0.3f) inactiveTrackColor = Color.White.copy(alpha = 0.3f)
), ),
modifier = Modifier.height(24.dp) modifier = Modifier
.height(24.dp)
.semantics { contentDescription = "$label: ${formatValue(value)}" }
) )
} }
} }
/** /**
* Capture button with animation for capturing state. * Capture button with processing indicator.
*/ */
@Composable @Composable
private fun CaptureButton( private fun CaptureButton(
isCapturing: Boolean, isCapturing: Boolean,
isProcessing: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -758,16 +756,33 @@ private fun CaptureButton(
.size(outerSize) .size(outerSize)
.clip(CircleShape) .clip(CircleShape)
.border(4.dp, Color.White, CircleShape) .border(4.dp, Color.White, CircleShape)
.clickable(enabled = !isCapturing, onClick = onClick), .clickable(
enabled = !isCapturing,
role = Role.Button,
onClick = onClick
)
.semantics {
contentDescription = "Capture photo with tilt-shift effect"
if (isCapturing) stateDescription = "Processing photo"
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(innerSize) .size(innerSize)
.clip(CircleShape) .clip(CircleShape)
.background(if (isCapturing) Color(0xFFFFB300) else Color.White) .background(if (isCapturing) AppColors.Accent else Color.White),
contentAlignment = Alignment.Center
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.Black,
strokeWidth = 3.dp
) )
} }
}
}
} }
/** /**
@ -789,13 +804,13 @@ private fun LastPhotoThumbnail(
thumbnail?.let { bmp -> thumbnail?.let { bmp ->
Image( Image(
bitmap = bmp.asImageBitmap(), bitmap = bmp.asImageBitmap(),
contentDescription = "Last captured photo", contentDescription = "Last captured photo. Tap to open in viewer.",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(52.dp) .size(52.dp)
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.border(2.dp, Color.White, RoundedCornerShape(10.dp)) .border(2.dp, Color.White, RoundedCornerShape(10.dp))
.clickable(onClick = onTap) .clickable(role = Role.Button, onClick = onTap)
) )
} }
} }

View file

@ -0,0 +1,209 @@
package no.naiv.tiltshift.ui
import android.app.Application
import android.graphics.Bitmap
import android.location.Location
import android.net.Uri
import android.util.Log
import android.view.Surface
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import no.naiv.tiltshift.camera.CameraManager
import no.naiv.tiltshift.camera.ImageCaptureHandler
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver
import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.HapticFeedback
import no.naiv.tiltshift.util.LocationProvider
import no.naiv.tiltshift.util.OrientationDetector
/**
* ViewModel for the camera screen.
* Survives configuration changes (rotation) and process death (via SavedStateHandle for primitives).
*/
class CameraViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "CameraViewModel"
}
val cameraManager = CameraManager(application)
val photoSaver = PhotoSaver(application)
val captureHandler = ImageCaptureHandler(application, photoSaver)
val haptics = HapticFeedback(application)
val orientationDetector = OrientationDetector(application)
val locationProvider = LocationProvider(application)
// Blur parameters — preserved across config changes
private val _blurParams = MutableStateFlow(BlurParameters.DEFAULT)
val blurParams: StateFlow<BlurParameters> = _blurParams.asStateFlow()
// Capture state
private val _isCapturing = MutableStateFlow(false)
val isCapturing: StateFlow<Boolean> = _isCapturing.asStateFlow()
private val _showSaveSuccess = MutableStateFlow(false)
val showSaveSuccess: StateFlow<Boolean> = _showSaveSuccess.asStateFlow()
private val _showSaveError = MutableStateFlow<String?>(null)
val showSaveError: StateFlow<String?> = _showSaveError.asStateFlow()
private val _showControls = MutableStateFlow(false)
val showControls: StateFlow<Boolean> = _showControls.asStateFlow()
// Thumbnail state
private val _lastSavedUri = MutableStateFlow<Uri?>(null)
val lastSavedUri: StateFlow<Uri?> = _lastSavedUri.asStateFlow()
private val _lastThumbnailBitmap = MutableStateFlow<Bitmap?>(null)
val lastThumbnailBitmap: StateFlow<Bitmap?> = _lastThumbnailBitmap.asStateFlow()
// Gallery preview state
private val _galleryBitmap = MutableStateFlow<Bitmap?>(null)
val galleryBitmap: StateFlow<Bitmap?> = _galleryBitmap.asStateFlow()
private val _galleryImageUri = MutableStateFlow<Uri?>(null)
val galleryImageUri: StateFlow<Uri?> = _galleryImageUri.asStateFlow()
val isGalleryPreview: Boolean get() = _galleryBitmap.value != null
// Device state
private val _currentRotation = MutableStateFlow(Surface.ROTATION_0)
val currentRotation: StateFlow<Int> = _currentRotation.asStateFlow()
private val _currentLocation = MutableStateFlow<Location?>(null)
val currentLocation: StateFlow<Location?> = _currentLocation.asStateFlow()
// Processing indicator
private val _isProcessing = MutableStateFlow(false)
val isProcessing: StateFlow<Boolean> = _isProcessing.asStateFlow()
fun updateBlurParams(params: BlurParameters) {
_blurParams.value = params
}
fun resetBlurParams() {
_blurParams.value = BlurParameters.DEFAULT
}
fun toggleControls() {
_showControls.value = !_showControls.value
}
fun updateRotation(rotation: Int) {
_currentRotation.value = rotation
}
fun updateLocation(location: Location?) {
_currentLocation.value = location
}
fun loadGalleryImage(uri: Uri) {
if (_isCapturing.value || isGalleryPreview) return
viewModelScope.launch {
_isProcessing.value = true
val bitmap = captureHandler.loadGalleryImage(uri)
_isProcessing.value = false
if (bitmap != null) {
_galleryBitmap.value = bitmap
_galleryImageUri.value = uri
} else {
haptics.error()
showError("Failed to load image")
}
}
}
fun cancelGalleryPreview() {
val old = _galleryBitmap.value
_galleryBitmap.value = null
_galleryImageUri.value = null
old?.recycle()
}
fun applyGalleryEffect() {
val uri = _galleryImageUri.value ?: return
if (_isCapturing.value) return
_isCapturing.value = true
_isProcessing.value = true
haptics.heavyClick()
viewModelScope.launch {
val result = captureHandler.processExistingImage(
imageUri = uri,
blurParams = _blurParams.value,
location = _currentLocation.value
)
handleSaveResult(result)
cancelGalleryPreview()
_isCapturing.value = false
_isProcessing.value = false
}
}
fun capturePhoto() {
if (_isCapturing.value) return
val imageCapture = cameraManager.imageCapture ?: return
_isCapturing.value = true
_isProcessing.value = true
haptics.heavyClick()
viewModelScope.launch {
val result = captureHandler.capturePhoto(
imageCapture = imageCapture,
executor = cameraManager.getExecutor(),
blurParams = _blurParams.value,
deviceRotation = _currentRotation.value,
location = _currentLocation.value,
isFrontCamera = cameraManager.isFrontCamera.value
)
handleSaveResult(result)
_isCapturing.value = false
_isProcessing.value = false
}
}
private fun handleSaveResult(result: SaveResult) {
when (result) {
is SaveResult.Success -> {
haptics.success()
val oldThumb = _lastThumbnailBitmap.value
_lastThumbnailBitmap.value = result.thumbnail
_lastSavedUri.value = result.uri
oldThumb?.recycle()
viewModelScope.launch {
_showSaveSuccess.value = true
kotlinx.coroutines.delay(1500)
_showSaveSuccess.value = false
}
}
is SaveResult.Error -> {
haptics.error()
showError(result.message)
}
}
}
private fun showError(message: String) {
viewModelScope.launch {
_showSaveError.value = message
kotlinx.coroutines.delay(2000)
_showSaveError.value = null
}
}
fun showCameraError(message: String) {
showError(message)
}
override fun onCleared() {
super.onCleared()
cameraManager.release()
_lastThumbnailBitmap.value?.recycle()
_galleryBitmap.value?.recycle()
}
}

View file

@ -16,10 +16,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.camera.CameraLens import no.naiv.tiltshift.camera.CameraLens
import no.naiv.tiltshift.ui.theme.AppColors
/** /**
* Lens selection UI for switching between camera lenses. * Lens selection UI for switching between camera lenses.
@ -59,9 +65,9 @@ private fun LensButton(
) { ) {
val backgroundColor by animateColorAsState( val backgroundColor by animateColorAsState(
targetValue = if (isSelected) { targetValue = if (isSelected) {
Color(0xFFFFB300) AppColors.Accent
} else { } else {
Color(0x80000000) AppColors.OverlayDark
}, },
label = "lens_button_bg" label = "lens_button_bg"
) )
@ -76,7 +82,8 @@ private fun LensButton(
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(backgroundColor) .background(backgroundColor)
.clickable(onClick = onClick), .clickable(role = Role.Button, onClick = onClick)
.semantics { selected = isSelected; contentDescription = "${lens.displayName} lens" },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(

View file

@ -22,9 +22,12 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.ui.theme.AppColors
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
@ -39,13 +42,16 @@ private enum class GestureType {
DRAG_POSITION, // Single finger drag to move focus center DRAG_POSITION, // Single finger drag to move focus center
ROTATE, // Two-finger rotation ROTATE, // Two-finger rotation
PINCH_SIZE, // Pinch near blur edges to resize PINCH_SIZE, // Pinch near blur edges to resize
PINCH_ZOOM // Pinch in center to zoom camera PINCH_ZOOM // Pinch far outside to zoom camera
} }
// Sensitivity factor for size pinch (lower = less sensitive) // Sensitivity factor for size pinch (lower = less sensitive)
private const val SIZE_SENSITIVITY = 0.3f private const val SIZE_SENSITIVITY = 0.3f
private const val ZOOM_SENSITIVITY = 0.5f private const val ZOOM_SENSITIVITY = 0.5f
/** Minimum focus size (in px) for gesture zones to ensure usable touch targets. */
private const val MIN_FOCUS_SIZE_PX = 150f
/** /**
* Calculates the angle between two touch points. * Calculates the angle between two touch points.
*/ */
@ -70,9 +76,16 @@ fun TiltShiftOverlay(
var currentGesture by remember { mutableStateOf(GestureType.NONE) } var currentGesture by remember { mutableStateOf(GestureType.NONE) }
val modeLabel = if (params.mode == BlurMode.LINEAR) "linear" else "radial"
Canvas( Canvas(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.semantics {
contentDescription = "Tilt-shift overlay: $modeLabel mode. " +
"Drag to move focus. Pinch near edges to resize. " +
"Pinch near center to rotate. Use sliders in controls panel for alternative adjustment."
}
.pointerInput(Unit) { .pointerInput(Unit) {
awaitEachGesture { awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false) val firstDown = awaitFirstDown(requireUnconsumed = false)
@ -187,6 +200,8 @@ fun TiltShiftOverlay(
* - Very center (< 30% of focus size): Rotation * - Very center (< 30% of focus size): Rotation
* - Near focus region (30% - 200% of focus size): Size adjustment * - Near focus region (30% - 200% of focus size): Size adjustment
* - Far outside (> 200%): Camera zoom * - Far outside (> 200%): Camera zoom
*
* Focus size is clamped to a minimum pixel value to keep zones usable at small sizes.
*/ */
private fun determineGestureType( private fun determineGestureType(
centroid: Offset, centroid: Offset,
@ -196,7 +211,8 @@ private fun determineGestureType(
): GestureType { ): GestureType {
val focusCenterX = width * params.positionX val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY val focusCenterY = height * params.positionY
val focusSize = height * params.size * 0.5f // Clamp focus size to minimum to keep rotation zone reachable
val focusSize = maxOf(height * params.size * 0.5f, MIN_FOCUS_SIZE_PX)
val dx = centroid.x - focusCenterX val dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY val dy = centroid.y - focusCenterY
@ -236,6 +252,7 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
/** /**
* Draws the linear mode overlay (horizontal band with rotation). * Draws the linear mode overlay (horizontal band with rotation).
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
*/ */
private fun DrawScope.drawLinearOverlay(params: BlurParameters) { private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val width = size.width val width = size.width
@ -246,15 +263,20 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val focusHalfHeight = height * params.size * 0.5f val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat()) val angleDegrees = params.angle * (180f / PI.toFloat())
// Colors for overlay val focusLineColor = AppColors.Accent
val focusLineColor = Color(0xFFFFB300) // Amber val outlineColor = AppColors.OverlayOutline
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white val blurZoneColor = AppColors.OverlayLinearBlur
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f) val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
// Calculate diagonal for extended drawing (ensures coverage when rotated) // Calculate diagonal for extended drawing (ensures coverage when rotated)
val diagonal = sqrt(width * width + height * height) val diagonal = sqrt(width * width + height * height)
val extendedHalf = diagonal val extendedHalf = diagonal
val outlineWidth = 4.dp.toPx()
val lineWidth = 2.dp.toPx()
val centerLineWidth = 3.dp.toPx()
val centerOutlineWidth = 5.dp.toPx()
rotate(angleDegrees, pivot = Offset(centerX, centerY)) { rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw blur zone indicators (top and bottom) // Draw blur zone indicators (top and bottom)
drawRect( drawRect(
@ -268,32 +290,60 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
size = Size(extendedHalf * 2, extendedHalf) size = Size(extendedHalf * 2, extendedHalf)
) )
// Draw focus zone boundary lines // Draw focus zone boundary lines (outline first, then color)
// Top boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight), start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight), end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = 2.dp.toPx(), strokeWidth = lineWidth,
pathEffect = dashEffect
)
// Bottom boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect pathEffect = dashEffect
) )
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight), start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight), end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = 2.dp.toPx(), strokeWidth = lineWidth,
pathEffect = dashEffect pathEffect = dashEffect
) )
// Draw center focus line // Draw center focus line (outline + color)
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY),
strokeWidth = centerOutlineWidth
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY), start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY), end = Offset(centerX + extendedHalf, centerY),
strokeWidth = 3.dp.toPx() strokeWidth = centerLineWidth
) )
// Draw rotation indicator at center // Draw rotation indicator at center
val indicatorRadius = 30.dp.toPx() val indicatorRadius = 30.dp.toPx()
drawCircle(
color = outlineColor,
radius = indicatorRadius,
center = Offset(centerX, centerY),
style = Stroke(width = 4.dp.toPx())
)
drawCircle( drawCircle(
color = focusLineColor.copy(alpha = 0.5f), color = focusLineColor.copy(alpha = 0.5f),
radius = indicatorRadius, radius = indicatorRadius,
@ -303,6 +353,12 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
// Draw angle tick mark // Draw angle tick mark
val tickLength = 15.dp.toPx() val tickLength = 15.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength),
end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()),
strokeWidth = 5.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength), start = Offset(centerX, centerY - indicatorRadius + tickLength),
@ -314,6 +370,7 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
/** /**
* Draws the radial mode overlay (ellipse/circle). * Draws the radial mode overlay (ellipse/circle).
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
*/ */
private fun DrawScope.drawRadialOverlay(params: BlurParameters) { private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val width = size.width val width = size.width
@ -324,9 +381,8 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val focusRadius = height * params.size * 0.5f val focusRadius = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat()) val angleDegrees = params.angle * (180f / PI.toFloat())
// Colors for overlay val focusLineColor = AppColors.Accent
val focusLineColor = Color(0xFFFFB300) // Amber val outlineColor = AppColors.OverlayOutline
val blurZoneColor = Color(0x30FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f) val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f)
// Calculate ellipse dimensions based on aspect ratio // Calculate ellipse dimensions based on aspect ratio
@ -334,7 +390,13 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val ellipseHeight = focusRadius * 2 val ellipseHeight = focusRadius * 2
rotate(angleDegrees, pivot = Offset(centerX, centerY)) { rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw focus ellipse outline (inner boundary) // Draw focus ellipse outline (outline + color)
drawOval(
color = outlineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
size = Size(ellipseWidth, ellipseHeight),
style = Stroke(width = 5.dp.toPx())
)
drawOval( drawOval(
color = focusLineColor, color = focusLineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2), topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
@ -344,6 +406,15 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
// Draw outer blur boundary (with falloff) // Draw outer blur boundary (with falloff)
val outerScale = 1f + params.falloff val outerScale = 1f + params.falloff
drawOval(
color = outlineColor,
topLeft = Offset(
centerX - (ellipseWidth * outerScale) / 2,
centerY - (ellipseHeight * outerScale) / 2
),
size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale),
style = Stroke(width = 4.dp.toPx(), pathEffect = dashEffect)
)
drawOval( drawOval(
color = focusLineColor.copy(alpha = 0.5f), color = focusLineColor.copy(alpha = 0.5f),
topLeft = Offset( topLeft = Offset(
@ -354,14 +425,26 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect) style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect)
) )
// Draw center crosshair // Draw center crosshair (outline + color)
val crosshairSize = 20.dp.toPx() val crosshairSize = 20.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 4.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - crosshairSize, centerY), start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY), end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
) )
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - crosshairSize),
end = Offset(centerX, centerY + crosshairSize),
strokeWidth = 4.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - crosshairSize), start = Offset(centerX, centerY - crosshairSize),
@ -369,7 +452,13 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
) )
// Draw rotation indicator (small line at top of ellipse) // Draw rotation indicator (small line at top of ellipse, outline + color)
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),
end = Offset(centerX, centerY - ellipseHeight / 2 - 20.dp.toPx()),
strokeWidth = 5.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()), start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),

View file

@ -16,9 +16,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.ui.theme.AppColors
import kotlin.math.abs import kotlin.math.abs
/** /**
@ -65,9 +71,9 @@ private fun ZoomButton(
) { ) {
val backgroundColor by animateColorAsState( val backgroundColor by animateColorAsState(
targetValue = if (isSelected) { targetValue = if (isSelected) {
Color(0xFFFFB300) // Amber when selected AppColors.Accent
} else { } else {
Color(0x80000000) // Semi-transparent black AppColors.OverlayDark
}, },
label = "zoom_button_bg" label = "zoom_button_bg"
) )
@ -79,10 +85,11 @@ private fun ZoomButton(
Box( Box(
modifier = Modifier modifier = Modifier
.size(44.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(backgroundColor) .background(backgroundColor)
.clickable(onClick = onClick), .clickable(role = Role.Button, onClick = onClick)
.semantics { selected = isSelected; contentDescription = "${preset.label}x zoom" },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@ -105,8 +112,9 @@ fun ZoomIndicator(
Box( Box(
modifier = modifier modifier = modifier
.clip(CircleShape) .clip(CircleShape)
.background(Color(0x80000000)) .background(AppColors.OverlayDark)
.padding(horizontal = 12.dp, vertical = 6.dp), .padding(horizontal = 12.dp, vertical = 6.dp)
.semantics { contentDescription = "Current zoom: %.1fx".format(currentZoom) },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(

View file

@ -0,0 +1,18 @@
package no.naiv.tiltshift.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Centralized color definitions for the Tilt-Shift Camera app.
*/
object AppColors {
val Accent = Color(0xFFFFB300)
val OverlayDark = Color(0x80000000)
val OverlayDarker = Color(0xCC000000)
val Success = Color(0xFF4CAF50)
val Error = Color(0xFFF44336)
val OverlayLinearBlur = Color(0x40FFFFFF)
val OverlayRadialBlur = Color(0x30FFFFFF)
/** Dark outline behind overlay guide lines for visibility over bright scenes. */
val OverlayOutline = Color(0x80000000)
}

View file

@ -14,6 +14,7 @@ playServicesLocation = "21.3.0"
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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 = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui = { group = "androidx.compose.ui", name = "ui" }