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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 11:46:05 +01:00
commit 5d80dcfcbe
2 changed files with 228 additions and 119 deletions

View file

@ -189,6 +189,21 @@ class ImageCaptureHandler(
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) 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. * 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. * Loads the image, applies EXIF rotation, processes the effect, and saves both versions.

View file

@ -113,40 +113,31 @@ fun CameraScreen(
var lastSavedUri by remember { mutableStateOf<Uri?>(null) } var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) } var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Gallery preview mode: non-null means we're previewing a gallery image
var galleryBitmap by remember { mutableStateOf<Bitmap?>(null) }
var galleryImageUri by remember { mutableStateOf<Uri?>(null) }
val isGalleryPreview = galleryBitmap != null
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
var currentLocation by remember { mutableStateOf<Location?>(null) } var currentLocation by remember { mutableStateOf<Location?>(null) }
// Gallery picker: process a selected image through the tilt-shift pipeline // Gallery picker: load image for interactive preview before processing
val galleryLauncher = rememberLauncherForActivityResult( val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia() contract = ActivityResultContracts.PickVisualMedia()
) { uri -> ) { uri ->
if (uri != null && !isCapturing) { if (uri != null && !isCapturing && !isGalleryPreview) {
isCapturing = true
scope.launch { scope.launch {
val result = captureHandler.processExistingImage( val bitmap = captureHandler.loadGalleryImage(uri)
imageUri = uri, if (bitmap != null) {
blurParams = blurParams, galleryBitmap = bitmap
location = currentLocation galleryImageUri = uri
) } else {
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() haptics.error()
showSaveError = result.message showSaveError = "Failed to load image"
delay(2000) delay(2000)
showSaveError = null showSaveError = null
} }
} }
isCapturing = false
}
} }
} }
@ -205,6 +196,7 @@ fun CameraScreen(
cameraManager.release() cameraManager.release()
renderer?.release() renderer?.release()
lastThumbnailBitmap?.recycle() lastThumbnailBitmap?.recycle()
galleryBitmap?.recycle()
} }
} }
@ -213,6 +205,19 @@ fun CameraScreen(
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .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 // OpenGL Surface for camera preview with effect
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
@ -232,6 +237,7 @@ fun CameraScreen(
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
}
// Tilt-shift overlay (gesture handling + visualization) // Tilt-shift overlay (gesture handling + visualization)
TiltShiftOverlay( TiltShiftOverlay(
@ -241,8 +247,10 @@ fun CameraScreen(
haptics.tick() haptics.tick()
}, },
onZoomChange = { zoomDelta -> onZoomChange = { zoomDelta ->
if (!isGalleryPreview) {
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
cameraManager.setZoom(newZoom) cameraManager.setZoom(newZoom)
}
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@ -259,10 +267,15 @@ fun CameraScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (!isGalleryPreview) {
// Zoom indicator // Zoom indicator
ZoomIndicator(currentZoom = zoomRatio) ZoomIndicator(currentZoom = zoomRatio)
} else {
Spacer(modifier = Modifier.width(1.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (!isGalleryPreview) {
// Camera flip button // Camera flip button
IconButton( IconButton(
onClick = { onClick = {
@ -276,6 +289,7 @@ fun CameraScreen(
tint = Color.White tint = Color.White
) )
} }
}
// Toggle controls button // Toggle controls button
IconButton( IconButton(
@ -336,6 +350,85 @@ fun CameraScreen(
.systemGestureExclusion(), .systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally 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) // Zoom presets (only show for back camera)
if (!isFrontCamera) { if (!isFrontCamera) {
ZoomControl( ZoomControl(
@ -424,9 +517,10 @@ fun CameraScreen(
Spacer(modifier = Modifier.size(52.dp)) Spacer(modifier = Modifier.size(52.dp))
} }
} }
}
// Last captured photo thumbnail // Last captured photo thumbnail (hidden in gallery preview mode)
LastPhotoThumbnail( if (!isGalleryPreview) LastPhotoThumbnail(
thumbnail = lastThumbnailBitmap, thumbnail = lastThumbnailBitmap,
onTap = { onTap = {
lastSavedUri?.let { uri -> lastSavedUri?.let { uri ->