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,39 +113,30 @@ 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 -> {
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 ->