tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt

827 lines
30 KiB
Kotlin
Raw Normal View History

package no.naiv.tiltshift.ui
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.SurfaceTexture
import android.opengl.GLSurfaceView
import android.util.Log
import android.view.Surface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
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
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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 androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collectLatest
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer
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,
viewModel: CameraViewModel = viewModel()
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// 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) }
// 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 galleryPreviewBitmap by viewModel.galleryPreviewBitmap.collectAsState()
val isGalleryPreview = galleryPreviewBitmap != 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
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) {
viewModel.loadGalleryImage(uri)
}
}
// Show camera errors
LaunchedEffect(cameraError) {
cameraError?.let { message ->
viewModel.showCameraError(message)
viewModel.cameraManager.clearError()
}
}
// Collect orientation updates
LaunchedEffect(Unit) {
viewModel.orientationDetector.orientationFlow().collectLatest { rotation ->
viewModel.updateRotation(rotation)
}
}
// Collect location updates
LaunchedEffect(Unit) {
viewModel.locationProvider.locationFlow().collectLatest { location ->
viewModel.updateLocation(location)
}
}
// Update renderer with blur params
LaunchedEffect(blurParams) {
renderer?.updateParameters(blurParams)
glSurfaceView?.requestRender()
}
// Update renderer when camera switches (front/back)
LaunchedEffect(isFrontCamera) {
renderer?.setFrontCamera(isFrontCamera)
glSurfaceView?.requestRender()
}
// Start camera when surface texture is available
LaunchedEffect(surfaceTexture) {
surfaceTexture?.let {
viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
}
}
Fix concurrency, lifecycle, performance, and config issues from audit Concurrency & bitmap lifecycle: - Defer bitmap recycling by one cycle so Compose finishes drawing before native memory is freed (preview bitmaps, thumbnails) - Make galleryPreviewSource @Volatile for cross-thread visibility - Join preview job before recycling source bitmap in cancelGalleryPreview() to prevent use-after-free during CPU blur loop - Add @Volatile to TiltShiftRenderer.currentTexCoords (UI/GL thread race) - Fix error dismiss race with cancellable Job tracking Lifecycle & resource management: - Release GL resources via glSurfaceView.queueEvent (must run on GL thread) - Pause GLSurfaceView when entering gallery preview mode - Shut down captureExecutor in CameraManager.release() (thread leak) - Use WeakReference for lifecycleOwnerRef to avoid Activity GC delay - Fix thumbnail bitmap leak on coroutine cancellation (add to finally) - Guarantee imageProxy.close() in finally block Performance: - Compute gradient mask at 1/4 resolution with bilinear upscale (~93% less per-pixel trig work, ~75% less mask memory) - Precompute cos/sin on CPU, pass as uCosAngle/uSinAngle uniforms (eliminates per-fragment transcendental calls in GLSL) - Unroll 9-tap Gaussian blur kernel (avoids integer-branched weight lookup that de-optimizes on mobile GPUs) - Add 80ms debounce to preview recomputation during slider drags Silent failure fixes: - Check bitmap.compress() return value; report error on failure - Log all loadBitmapFromUri null paths (stream, dimensions, decode) - Surface preview computation errors and ActivityNotFoundException to user - Return boolean from writeExifToUri, log at ERROR level - Wrap gallery preview downscale in try-catch (OOM protection) Config: - Add ACCESS_MEDIA_LOCATION permission (GPS EXIF on Android 10+) - Accept coarse-only location grant for geotags - Remove dead adjustResize (no effect with edge-to-edge) - Set windowBackground to black (eliminates white flash on cold start) - Add values-night theme for dark mode - Remove overly broad ProGuard keeps (CameraX/GMS ship consumer rules) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:44:12 +01:00
// Pause/resume GLSurfaceView when entering/leaving gallery preview
LaunchedEffect(isGalleryPreview) {
if (isGalleryPreview) {
glSurfaceView?.onPause()
} else {
glSurfaceView?.onResume()
}
}
// Cleanup GL resources on GL thread (ViewModel handles its own cleanup in onCleared)
DisposableEffect(Unit) {
onDispose {
Fix concurrency, lifecycle, performance, and config issues from audit Concurrency & bitmap lifecycle: - Defer bitmap recycling by one cycle so Compose finishes drawing before native memory is freed (preview bitmaps, thumbnails) - Make galleryPreviewSource @Volatile for cross-thread visibility - Join preview job before recycling source bitmap in cancelGalleryPreview() to prevent use-after-free during CPU blur loop - Add @Volatile to TiltShiftRenderer.currentTexCoords (UI/GL thread race) - Fix error dismiss race with cancellable Job tracking Lifecycle & resource management: - Release GL resources via glSurfaceView.queueEvent (must run on GL thread) - Pause GLSurfaceView when entering gallery preview mode - Shut down captureExecutor in CameraManager.release() (thread leak) - Use WeakReference for lifecycleOwnerRef to avoid Activity GC delay - Fix thumbnail bitmap leak on coroutine cancellation (add to finally) - Guarantee imageProxy.close() in finally block Performance: - Compute gradient mask at 1/4 resolution with bilinear upscale (~93% less per-pixel trig work, ~75% less mask memory) - Precompute cos/sin on CPU, pass as uCosAngle/uSinAngle uniforms (eliminates per-fragment transcendental calls in GLSL) - Unroll 9-tap Gaussian blur kernel (avoids integer-branched weight lookup that de-optimizes on mobile GPUs) - Add 80ms debounce to preview recomputation during slider drags Silent failure fixes: - Check bitmap.compress() return value; report error on failure - Log all loadBitmapFromUri null paths (stream, dimensions, decode) - Surface preview computation errors and ActivityNotFoundException to user - Return boolean from writeExifToUri, log at ERROR level - Wrap gallery preview downscale in try-catch (OOM protection) Config: - Add ACCESS_MEDIA_LOCATION permission (GPS EXIF on Android 10+) - Accept coarse-only location grant for geotags - Remove dead adjustResize (no effect with edge-to-edge) - Set windowBackground to black (eliminates white flash on cold start) - Add values-night theme for dark mode - Remove overly broad ProGuard keeps (CameraX/GMS ship consumer rules) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:44:12 +01:00
glSurfaceView?.queueEvent { renderer?.release() }
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
) {
// Main view: gallery preview image or camera GL surface
if (isGalleryPreview) {
galleryPreviewBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Gallery image preview with tilt-shift effect. Adjust parameters then tap Apply.",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
)
}
} else {
// OpenGL Surface for camera preview with effect
AndroidView(
factory = { ctx ->
GLSurfaceView(ctx).apply {
setEGLContextClientVersion(2)
val newRenderer = TiltShiftRenderer(ctx) { st ->
surfaceTexture = st
}
renderer = newRenderer
setRenderer(newRenderer)
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
glSurfaceView = this
}
},
modifier = Modifier.fillMaxSize()
)
}
// Tilt-shift overlay (gesture handling + visualization)
TiltShiftOverlay(
params = blurParams,
onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams)
},
onZoomChange = { zoomDelta ->
if (!isGalleryPreview) {
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
viewModel.cameraManager.setZoom(newZoom)
}
},
modifier = Modifier.fillMaxSize()
)
// Top bar with controls
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (!isGalleryPreview) {
ZoomIndicator(currentZoom = zoomRatio)
} else {
Spacer(modifier = Modifier.width(1.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (!isGalleryPreview) {
// Camera flip button
IconButton(
onClick = {
viewModel.cameraManager.switchCamera()
viewModel.haptics.click()
}
) {
Icon(
imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch between front and back camera",
tint = Color.White
)
}
}
// Toggle controls button (tune icon instead of cryptic "Ctrl")
IconButton(
onClick = {
viewModel.toggleControls()
viewModel.haptics.tick()
},
modifier = Modifier.semantics {
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
}
) {
Icon(
imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune,
contentDescription = if (showControls) "Hide blur controls" else "Show blur controls",
tint = Color.White
)
}
}
}
// Mode toggle
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.Center
) {
ModeToggle(
currentMode = blurParams.mode,
onModeChange = { mode ->
viewModel.updateBlurParams(blurParams.copy(mode = mode))
viewModel.haptics.click()
}
)
}
}
// Control panel (sliders)
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
) {
ControlPanel(
params = blurParams,
onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams)
},
onReset = { viewModel.resetBlurParams() }
)
}
// Bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 48.dp)
.systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isGalleryPreview) {
// Gallery preview mode: Cancel | Apply (matched layout to camera mode)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Cancel button (same 52dp as gallery button for layout consistency)
IconButton(
onClick = { viewModel.cancelGalleryPreview() },
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
.semantics { contentDescription = "Cancel gallery import" }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Apply button (same 72dp as capture button for layout consistency)
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.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 = 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
if (!isFrontCamera) {
ZoomControl(
currentZoom = zoomRatio,
minZoom = minZoom,
maxZoom = maxZoom,
onZoomSelected = { zoom ->
viewModel.cameraManager.setZoom(zoom)
viewModel.haptics.click()
}
)
Spacer(modifier = Modifier.height(24.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Gallery picker button (with background for discoverability)
IconButton(
onClick = {
if (!isCapturing) {
galleryLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
},
enabled = !isCapturing,
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
) {
Icon(
imageVector = Icons.Default.PhotoLibrary,
contentDescription = "Pick image from gallery",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Capture button
CaptureButton(
isCapturing = isCapturing,
isProcessing = isProcessing,
onClick = { viewModel.capturePhoto() }
)
// Spacer for visual symmetry with gallery button
Spacer(modifier = Modifier.size(52.dp))
}
}
}
// Last captured photo thumbnail (hidden in gallery preview mode)
if (!isGalleryPreview) LastPhotoThumbnail(
thumbnail = lastThumbnailBitmap,
onTap = {
lastSavedUri?.let { uri ->
try {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "image/jpeg")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) {
Log.w("CameraScreen", "No activity found to view image", e)
Fix concurrency, lifecycle, performance, and config issues from audit Concurrency & bitmap lifecycle: - Defer bitmap recycling by one cycle so Compose finishes drawing before native memory is freed (preview bitmaps, thumbnails) - Make galleryPreviewSource @Volatile for cross-thread visibility - Join preview job before recycling source bitmap in cancelGalleryPreview() to prevent use-after-free during CPU blur loop - Add @Volatile to TiltShiftRenderer.currentTexCoords (UI/GL thread race) - Fix error dismiss race with cancellable Job tracking Lifecycle & resource management: - Release GL resources via glSurfaceView.queueEvent (must run on GL thread) - Pause GLSurfaceView when entering gallery preview mode - Shut down captureExecutor in CameraManager.release() (thread leak) - Use WeakReference for lifecycleOwnerRef to avoid Activity GC delay - Fix thumbnail bitmap leak on coroutine cancellation (add to finally) - Guarantee imageProxy.close() in finally block Performance: - Compute gradient mask at 1/4 resolution with bilinear upscale (~93% less per-pixel trig work, ~75% less mask memory) - Precompute cos/sin on CPU, pass as uCosAngle/uSinAngle uniforms (eliminates per-fragment transcendental calls in GLSL) - Unroll 9-tap Gaussian blur kernel (avoids integer-branched weight lookup that de-optimizes on mobile GPUs) - Add 80ms debounce to preview recomputation during slider drags Silent failure fixes: - Check bitmap.compress() return value; report error on failure - Log all loadBitmapFromUri null paths (stream, dimensions, decode) - Surface preview computation errors and ActivityNotFoundException to user - Return boolean from writeExifToUri, log at ERROR level - Wrap gallery preview downscale in try-catch (OOM protection) Config: - Add ACCESS_MEDIA_LOCATION permission (GPS EXIF on Android 10+) - Accept coarse-only location grant for geotags - Remove dead adjustResize (no effect with edge-to-edge) - Set windowBackground to black (eliminates white flash on cold start) - Add values-night theme for dark mode - Remove overly broad ProGuard keeps (CameraX/GMS ship consumer rules) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:44:12 +01:00
viewModel.showCameraError("No app available to view photos")
}
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 48.dp, end = 16.dp)
)
// Success indicator (announced to accessibility)
AnimatedVisibility(
visible = showSaveSuccess,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.Center)
.semantics { liveRegion = LiveRegionMode.Polite }
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(AppColors.Success),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Photo saved successfully",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
}
// Error indicator (announced to accessibility)
AnimatedVisibility(
visible = showSaveError != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp)
.semantics { liveRegion = LiveRegionMode.Assertive }
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(AppColors.Error)
.padding(24.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = showSaveError ?: "Error",
color = Color.White,
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)
)
}
}
}
}
}
/**
* Mode toggle for Linear / Radial blur modes.
*/
@Composable
private fun ModeToggle(
currentMode: BlurMode,
onModeChange: (BlurMode) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.clip(RoundedCornerShape(20.dp))
.background(AppColors.OverlayDark)
.padding(4.dp),
horizontalArrangement = Arrangement.Center
) {
ModeButton(
text = "Linear",
isSelected = currentMode == BlurMode.LINEAR,
onClick = { onModeChange(BlurMode.LINEAR) }
)
Spacer(modifier = Modifier.width(4.dp))
ModeButton(
text = "Radial",
isSelected = currentMode == BlurMode.RADIAL,
onClick = { onModeChange(BlurMode.RADIAL) }
)
}
}
@Composable
private fun ModeButton(
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.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(
text = text,
color = if (isSelected) Color.Black else Color.White,
fontSize = 14.sp
)
}
}
/**
* 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
) {
val currentParams by rememberUpdatedState(params)
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
Column(
modifier = modifier
.width(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(AppColors.OverlayDarker)
.padding(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)) }
)
// Falloff slider
SliderControl(
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)) }
)
}
}
@Composable
private fun SliderControl(
label: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
onValueChange: (Float) -> Unit
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = Color.White,
fontSize = 12.sp
)
Text(
text = formatValue(value),
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp
)
}
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = AppColors.Accent,
activeTrackColor = AppColors.Accent,
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
),
modifier = Modifier
.height(24.dp)
.semantics { contentDescription = "$label: ${formatValue(value)}" }
)
}
}
/**
* Capture button with processing indicator.
*/
@Composable
private fun CaptureButton(
isCapturing: Boolean,
isProcessing: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val outerSize = 72.dp
val innerSize = if (isCapturing) 48.dp else 60.dp
Box(
modifier = modifier
.size(outerSize)
.clip(CircleShape)
.border(4.dp, Color.White, CircleShape)
.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) 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.
* Tapping opens the image in the default photo viewer.
*/
@Composable
private fun LastPhotoThumbnail(
thumbnail: Bitmap?,
onTap: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = thumbnail != null,
enter = fadeIn() + scaleIn(initialScale = 0.6f),
exit = fadeOut(),
modifier = modifier
) {
thumbnail?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
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(role = Role.Button, onClick = onTap)
)
}
}
}