package no.naiv.tiltshift.ui import android.graphics.SurfaceTexture import android.location.Location import android.opengl.GLSurfaceView import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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.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.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.rememberCoroutineScope 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.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.delay 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 /** * Main camera screen with tilt-shift controls. */ @Composable fun CameraScreen( modifier: Modifier = Modifier ) { 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) } var surfaceTexture by remember { mutableStateOf(null) } var renderer by remember { mutableStateOf(null) } var glSurfaceView by remember { mutableStateOf(null) } var isCapturing by remember { mutableStateOf(false) } var showSaveSuccess by remember { mutableStateOf(false) } var showSaveError by remember { mutableStateOf(null) } var showControls by remember { mutableStateOf(false) } var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentLocation by remember { mutableStateOf(null) } val zoomRatio by cameraManager.zoomRatio.collectAsState() val minZoom by cameraManager.minZoomRatio.collectAsState() val maxZoom by cameraManager.maxZoomRatio.collectAsState() val isFrontCamera by cameraManager.isFrontCamera.collectAsState() // Collect orientation updates LaunchedEffect(Unit) { orientationDetector.orientationFlow().collectLatest { rotation -> currentRotation = rotation } } // Collect location updates LaunchedEffect(Unit) { locationProvider.locationFlow().collectLatest { location -> currentLocation = 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 { cameraManager.startCamera(lifecycleOwner) { surfaceTexture } } } // Cleanup DisposableEffect(Unit) { onDispose { cameraManager.release() renderer?.release() } } Box( modifier = modifier .fillMaxSize() .background(Color.Black) ) { // 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 -> blurParams = newParams haptics.tick() }, onZoomChange = { zoomDelta -> val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) 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 ) { // Zoom indicator ZoomIndicator(currentZoom = zoomRatio) Row(verticalAlignment = Alignment.CenterVertically) { // Camera flip button IconButton( onClick = { cameraManager.switchCamera() haptics.click() } ) { Icon( imageVector = Icons.Default.FlipCameraAndroid, contentDescription = "Switch Camera", tint = Color.White ) } // Toggle controls button IconButton( onClick = { showControls = !showControls haptics.tick() } ) { Text( text = if (showControls) "Hide" else "Ctrl", color = Color.White, fontSize = 12.sp ) } } } // Mode toggle Row( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), horizontalArrangement = Arrangement.Center ) { ModeToggle( currentMode = blurParams.mode, onModeChange = { mode -> blurParams = blurParams.copy(mode = mode) 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 -> blurParams = newParams } ) } // Bottom controls Column( modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() .padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // Zoom presets (only show for back camera) if (!isFrontCamera) { ZoomControl( currentZoom = zoomRatio, minZoom = minZoom, maxZoom = maxZoom, onZoomSelected = { zoom -> cameraManager.setZoom(zoom) haptics.click() } ) Spacer(modifier = Modifier.height(24.dp)) } // 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() showSaveSuccess = true delay(1500) showSaveSuccess = false } is SaveResult.Error -> { haptics.error() showSaveError = result.message delay(2000) showSaveError = null } } } isCapturing = false } } } ) } // Success indicator AnimatedVisibility( visible = showSaveSuccess, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.align(Alignment.Center) ) { Box( modifier = Modifier .size(80.dp) .clip(CircleShape) .background(Color(0xFF4CAF50)), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Default.Check, contentDescription = "Saved", tint = Color.White, modifier = Modifier.size(48.dp) ) } } // Error indicator AnimatedVisibility( visible = showSaveError != null, enter = fadeIn(), exit = fadeOut(), modifier = Modifier .align(Alignment.Center) .padding(32.dp) ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .clip(RoundedCornerShape(16.dp)) .background(Color(0xFFF44336)) .padding(24.dp) ) { Icon( imageVector = Icons.Default.Close, contentDescription = "Error", tint = Color.White, modifier = Modifier.size(32.dp) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = showSaveError ?: "Error", color = Color.White, fontSize = 14.sp ) } } } } /** * 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(Color(0x80000000)) .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) Color(0xFFFFB300) else Color.Transparent) .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp), contentAlignment = Alignment.Center ) { Text( text = text, color = if (isSelected) Color.Black else Color.White, fontSize = 14.sp ) } } /** * Control panel with sliders for blur parameters. */ @Composable private fun ControlPanel( params: BlurParameters, onParamsChange: (BlurParameters) -> Unit, modifier: Modifier = Modifier ) { // Use rememberUpdatedState to avoid stale closure capture during slider drags val currentParams by rememberUpdatedState(params) val currentOnParamsChange by rememberUpdatedState(onParamsChange) Column( modifier = modifier .width(200.dp) .clip(RoundedCornerShape(16.dp)) .background(Color(0xCC000000)) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Blur intensity slider SliderControl( label = "Blur", value = params.blurAmount, valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR, onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) } ) // Falloff slider SliderControl( label = "Falloff", value = params.falloff, valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF, onValueChange = { currentOnParamsChange(currentParams.copy(falloff = 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, onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } ) } } } @Composable private fun SliderControl( label: String, value: Float, valueRange: ClosedFloatingPointRange, onValueChange: (Float) -> Unit ) { Column { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = label, color = Color.White, fontSize = 12.sp ) Text( text = "${(value * 100).toInt()}%", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp ) } Slider( value = value, onValueChange = onValueChange, valueRange = valueRange, colors = SliderDefaults.colors( thumbColor = Color(0xFFFFB300), activeTrackColor = Color(0xFFFFB300), inactiveTrackColor = Color.White.copy(alpha = 0.3f) ), modifier = Modifier.height(24.dp) ) } } /** * Capture button with animation for capturing state. */ @Composable private fun CaptureButton( isCapturing: 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, onClick = onClick), contentAlignment = Alignment.Center ) { Box( modifier = Modifier .size(innerSize) .clip(CircleShape) .background(if (isCapturing) Color(0xFFFFB300) else Color.White) ) } }