From 5d80dcfcbef47252a90fbba9705cc868f9ee39a0 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Thu, 5 Mar 2026 11:46:05 +0100 Subject: [PATCH] Add interactive gallery preview before applying tilt-shift effect Instead of immediately processing gallery images, show a preview where users can adjust blur parameters before committing. Adds Cancel/Apply buttons and hides camera-only controls during gallery preview mode. Co-Authored-By: Claude Opus 4.6 --- .../tiltshift/camera/ImageCaptureHandler.kt | 15 + .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 334 +++++++++++------- 2 files changed, 229 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt index 3f656fd..eea6c7f 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -189,6 +189,21 @@ class ImageCaptureHandler( return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } + /** + * Loads a gallery image and applies EXIF rotation, returning the bitmap for preview. + * The caller owns the returned bitmap and is responsible for recycling it. + */ + suspend fun loadGalleryImage(imageUri: Uri): Bitmap? = withContext(Dispatchers.IO) { + try { + val bitmap = loadBitmapFromUri(imageUri) + ?: return@withContext null + applyExifRotation(imageUri, bitmap) + } catch (e: Exception) { + Log.e(TAG, "Failed to load gallery image for preview", e) + null + } + } + /** * Processes an existing image from the gallery through the tilt-shift pipeline. * Loads the image, applies EXIF rotation, processes the effect, and saves both versions. diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index 790857c..a01d11e 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -113,39 +113,30 @@ fun CameraScreen( 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: process a selected image through the tilt-shift pipeline + // Gallery picker: load image for interactive preview before processing val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri -> - if (uri != null && !isCapturing) { - isCapturing = true + if (uri != null && !isCapturing && !isGalleryPreview) { 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 - } + val bitmap = captureHandler.loadGalleryImage(uri) + if (bitmap != null) { + galleryBitmap = bitmap + galleryImageUri = uri + } else { + haptics.error() + showSaveError = "Failed to load image" + delay(2000) + showSaveError = null } - isCapturing = false } } } @@ -205,6 +196,7 @@ fun CameraScreen( cameraManager.release() renderer?.release() lastThumbnailBitmap?.recycle() + galleryBitmap?.recycle() } } @@ -213,25 +205,39 @@ fun CameraScreen( .fillMaxSize() .background(Color.Black) ) { - // OpenGL Surface for camera preview with effect - AndroidView( - factory = { ctx -> - GLSurfaceView(ctx).apply { - setEGLContextClientVersion(2) + // 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 + val newRenderer = TiltShiftRenderer(ctx) { st -> + surfaceTexture = st + } + renderer = newRenderer + + setRenderer(newRenderer) + renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY + + glSurfaceView = this } - renderer = newRenderer - - setRenderer(newRenderer) - renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY - - glSurfaceView = this - } - }, - modifier = Modifier.fillMaxSize() - ) + }, + modifier = Modifier.fillMaxSize() + ) + } // Tilt-shift overlay (gesture handling + visualization) TiltShiftOverlay( @@ -241,8 +247,10 @@ fun CameraScreen( haptics.tick() }, onZoomChange = { zoomDelta -> - val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) - cameraManager.setZoom(newZoom) + if (!isGalleryPreview) { + val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) + cameraManager.setZoom(newZoom) + } }, modifier = Modifier.fillMaxSize() ) @@ -259,22 +267,28 @@ fun CameraScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Zoom indicator - ZoomIndicator(currentZoom = zoomRatio) + if (!isGalleryPreview) { + // Zoom indicator + ZoomIndicator(currentZoom = zoomRatio) + } else { + Spacer(modifier = Modifier.width(1.dp)) + } Row(verticalAlignment = Alignment.CenterVertically) { - // Camera flip button - IconButton( - onClick = { - cameraManager.switchCamera() - haptics.click() + if (!isGalleryPreview) { + // Camera flip button + IconButton( + onClick = { + cameraManager.switchCamera() + haptics.click() + } + ) { + Icon( + imageVector = Icons.Default.FlipCameraAndroid, + contentDescription = "Switch Camera", + tint = Color.White + ) } - ) { - Icon( - imageVector = Icons.Default.FlipCameraAndroid, - contentDescription = "Switch Camera", - tint = Color.White - ) } // Toggle controls button @@ -336,66 +350,45 @@ fun CameraScreen( .systemGestureExclusion(), 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)) - } - - // 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) + if (isGalleryPreview) { + // Gallery preview mode: Cancel | Apply + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(48.dp) ) { - Icon( - imageVector = Icons.Default.PhotoLibrary, - contentDescription = "Pick from gallery", - tint = Color.White, - modifier = Modifier.size(28.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) + ) + } - // 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(), + // 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, - deviceRotation = currentRotation, - location = currentLocation, - isFrontCamera = isFrontCamera + location = currentLocation ) - when (result) { is SaveResult.Success -> { haptics.success() @@ -413,20 +406,121 @@ fun CameraScreen( 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 } - isCapturing = false } } - } - ) + ) - // Spacer for visual symmetry with gallery button - Spacer(modifier = Modifier.size(52.dp)) + // Spacer for visual symmetry with gallery button + Spacer(modifier = Modifier.size(52.dp)) + } } } - // Last captured photo thumbnail - LastPhotoThumbnail( + // Last captured photo thumbnail (hidden in gallery preview mode) + if (!isGalleryPreview) LastPhotoThumbnail( thumbnail = lastThumbnailBitmap, onTap = { lastSavedUri?.let { uri ->