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