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:
parent
ee2b11941a
commit
efe406f0b0
1 changed files with 241 additions and 226 deletions
|
|
@ -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,17 +756,34 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded thumbnail of the last captured photo.
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue