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>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 12:23:43 +01:00
commit efe406f0b0

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,17 +756,34 @@ 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
) )
} }
} }
}
}
/** /**
* Rounded thumbnail of the last captured photo. * Rounded thumbnail of the last captured photo.
@ -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)
) )
} }
} }