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(null) } var renderer by remember { mutableStateOf(null) } var glSurfaceView 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 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 } } } // Cleanup GL resources (ViewModel handles its own cleanup in onCleared) DisposableEffect(Unit) { onDispose { renderer?.release() } } Box( modifier = modifier .fillMaxSize() .background(Color.Black) ) { // Main view: gallery preview image or camera GL surface if (isGalleryPreview) { galleryBitmap?.let { bmp -> Image( bitmap = bmp.asImageBitmap(), contentDescription = "Gallery image preview. Adjust tilt-shift 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) } } }, 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, 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) ) } } }