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.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.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.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.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.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) } // Thumbnail state for last captured photo var lastSavedUri by remember { mutableStateOf(null) } var lastThumbnailBitmap by remember { mutableStateOf(null) } // Gallery preview mode: non-null means we're previewing a gallery image var galleryBitmap by remember { mutableStateOf(null) } var galleryImageUri by remember { mutableStateOf(null) } val isGalleryPreview = galleryBitmap != null var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentLocation by remember { mutableStateOf(null) } // Gallery picker: load image for interactive preview before processing 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 } } } } 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 LaunchedEffect(cameraError) { cameraError?.let { message -> showSaveError = message cameraManager.clearError() delay(2000) showSaveError = null } } // 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() lastThumbnailBitmap?.recycle() galleryBitmap?.recycle() } } 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 preview", 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 -> blurParams = newParams haptics.tick() }, onZoomChange = { zoomDelta -> if (!isGalleryPreview) { 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 ) { if (!isGalleryPreview) { // Zoom indicator ZoomIndicator(currentZoom = zoomRatio) } else { Spacer(modifier = Modifier.width(1.dp)) } Row(verticalAlignment = Alignment.CenterVertically) { if (!isGalleryPreview) { // 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 = 48.dp) .systemGestureExclusion(), horizontalAlignment = Alignment.CenterHorizontally ) { if (isGalleryPreview) { // Gallery preview mode: Cancel | Apply Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(48.dp) ) { // Cancel button IconButton( onClick = { galleryBitmap?.recycle() galleryBitmap = null galleryImageUri = null }, modifier = Modifier .size(56.dp) .clip(CircleShape) .background(Color(0x80000000)) ) { Icon( imageVector = Icons.Default.Close, contentDescription = "Cancel", 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() lastThumbnailBitmap?.recycle() lastThumbnailBitmap = result.thumbnail lastSavedUri = result.uri showSaveSuccess = true delay(1500) showSaveSuccess = false } is SaveResult.Error -> { haptics.error() showSaveError = result.message delay(2000) showSaveError = null } } galleryBitmap?.recycle() galleryBitmap = null galleryImageUri = null isCapturing = false } } }, enabled = !isCapturing, modifier = Modifier .size(56.dp) .clip(CircleShape) .background(Color(0xFFFFB300)) ) { Icon( imageVector = Icons.Default.Check, contentDescription = "Apply effect", tint = Color.Black, modifier = Modifier.size(28.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() } ) 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 IconButton( onClick = { if (!isCapturing) { galleryLauncher.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) } }, enabled = !isCapturing, modifier = Modifier.size(52.dp) ) { Icon( imageVector = Icons.Default.PhotoLibrary, contentDescription = "Pick from gallery", tint = Color.White, modifier = Modifier.size(28.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() lastThumbnailBitmap?.recycle() lastThumbnailBitmap = result.thumbnail lastSavedUri = result.uri showSaveSuccess = true delay(1500) showSaveSuccess = false } is SaveResult.Error -> { haptics.error() showSaveError = result.message delay(2000) showSaveError = null } } } isCapturing = false } } } ) // 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 -> val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "image/jpeg") addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(intent) } }, modifier = Modifier .align(Alignment.BottomEnd) .navigationBarsPadding() .padding(bottom = 48.dp, end = 16.dp) ) // 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) ) } } /** * 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", contentScale = ContentScale.Crop, modifier = Modifier .size(52.dp) .clip(RoundedCornerShape(10.dp)) .border(2.dp, Color.White, RoundedCornerShape(10.dp)) .clickable(onClick = onTap) ) } } }