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)
}
/**
* 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.

View file

@ -113,40 +113,31 @@ fun CameraScreen(
var lastSavedUri by remember { mutableStateOf<Uri?>(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 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(
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 -> {
val bitmap = captureHandler.loadGalleryImage(uri)
if (bitmap != null) {
galleryBitmap = bitmap
galleryImageUri = uri
} else {
haptics.error()
showSaveError = result.message
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,6 +205,19 @@ fun CameraScreen(
.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 ->
@ -232,6 +237,7 @@ fun CameraScreen(
},
modifier = Modifier.fillMaxSize()
)
}
// Tilt-shift overlay (gesture handling + visualization)
TiltShiftOverlay(
@ -241,8 +247,10 @@ fun CameraScreen(
haptics.tick()
},
onZoomChange = { zoomDelta ->
if (!isGalleryPreview) {
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
cameraManager.setZoom(newZoom)
}
},
modifier = Modifier.fillMaxSize()
)
@ -259,10 +267,15 @@ fun CameraScreen(
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 = {
@ -276,6 +289,7 @@ fun CameraScreen(
tint = Color.White
)
}
}
// Toggle controls button
IconButton(
@ -336,6 +350,85 @@ fun CameraScreen(
.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(
@ -424,9 +517,10 @@ fun CameraScreen(
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 ->