package no.naiv.tiltshift.ui import android.content.Intent import android.graphics.SurfaceTexture import android.opengl.GLSurfaceView import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image 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.foundation.systemGestureExclusion 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.LocationOff import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.PhotoLibrary 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.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.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion 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.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest 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 galleryPreviewBitmap by viewModel.galleryPreviewBitmap.collectAsState() val geotagEnabled by viewModel.geotagEnabled.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 previewResolution by viewModel.cameraManager.previewResolution.collectAsState() val cameraError by viewModel.cameraManager.error.collectAsState() val currentRotation by viewModel.currentRotation.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() } // Update renderer with camera preview resolution for crop-to-fill LaunchedEffect(previewResolution) { if (previewResolution.width > 0) { renderer?.setCameraResolution(previewResolution.width, previewResolution.height) glSurfaceView?.requestRender() } } // Forward device rotation to renderer (aspect math) and CameraX (target rotation) LaunchedEffect(currentRotation, renderer) { renderer?.setDisplayRotation(currentRotation) viewModel.cameraManager.setTargetRotation(currentRotation) glSurfaceView?.requestRender() } // Start camera when surface texture is available LaunchedEffect(surfaceTexture) { surfaceTexture?.let { viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture } } } // Pause/resume GLSurfaceView when entering/leaving gallery preview LaunchedEffect(isGalleryPreview) { if (isGalleryPreview) { glSurfaceView?.onPause() } else if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { glSurfaceView?.onResume() } } // Tie GLSurfaceView lifecycle to Activity lifecycle to prevent background rendering val currentIsGalleryPreview by rememberUpdatedState(isGalleryPreview) DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_RESUME -> { if (!currentIsGalleryPreview) { glSurfaceView?.onResume() } } Lifecycle.Event.ON_PAUSE -> glSurfaceView?.onPause() else -> {} } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) 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 view = this val newRenderer = TiltShiftRenderer( context = ctx, onSurfaceTextureAvailable = { st -> surfaceTexture = st }, onFrameAvailable = { view.requestRender() } ) renderer = newRenderer setRenderer(newRenderer) renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY 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) { // GPS geotagging toggle IconButton( onClick = { viewModel.toggleGeotag() viewModel.haptics.tick() } ) { Icon( imageVector = if (geotagEnabled) Icons.Default.LocationOn else Icons.Default.LocationOff, contentDescription = if (geotagEnabled) "Disable GPS geotagging" else "Enable GPS geotagging", tint = if (geotagEnabled) Color.White else Color.White.copy(alpha = 0.5f) ) } 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) 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) ) } } } } }