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.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<SurfaceTexture?>(null) }
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
var isCapturing by remember { mutableStateOf(false) }
var showSaveSuccess by remember { mutableStateOf(false) }
var showSaveError by remember { mutableStateOf<String?>(null) }
var showControls by remember { mutableStateOf(false) }
// Thumbnail state for last captured photo
var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Gallery preview mode: non-null means we're previewing a gallery image
var galleryBitmap by remember { mutableStateOf<Bitmap?>(null) }
var galleryImageUri by remember { mutableStateOf<Uri?>(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<Location?>(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
) {
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 = "Apply effect",
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<Float>,
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,16 +756,33 @@ 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)
)
}
}