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,39 +113,30 @@ 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) { haptics.error()
is SaveResult.Success -> { showSaveError = "Failed to load image"
haptics.success() delay(2000)
lastThumbnailBitmap?.recycle() showSaveError = null
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
} }
} }
} }
@ -205,6 +196,7 @@ fun CameraScreen(
cameraManager.release() cameraManager.release()
renderer?.release() renderer?.release()
lastThumbnailBitmap?.recycle() lastThumbnailBitmap?.recycle()
galleryBitmap?.recycle()
} }
} }
@ -213,25 +205,39 @@ fun CameraScreen(
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
) { ) {
// OpenGL Surface for camera preview with effect // Main view: gallery preview image or camera GL surface
AndroidView( if (isGalleryPreview) {
factory = { ctx -> galleryBitmap?.let { bmp ->
GLSurfaceView(ctx).apply { Image(
setEGLContextClientVersion(2) 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 -> val newRenderer = TiltShiftRenderer(ctx) { st ->
surfaceTexture = st surfaceTexture = st
}
renderer = newRenderer
setRenderer(newRenderer)
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
glSurfaceView = this
} }
renderer = newRenderer },
modifier = Modifier.fillMaxSize()
setRenderer(newRenderer) )
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY }
glSurfaceView = this
}
},
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 ->
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) if (!isGalleryPreview) {
cameraManager.setZoom(newZoom) val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
cameraManager.setZoom(newZoom)
}
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@ -259,22 +267,28 @@ fun CameraScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Zoom indicator if (!isGalleryPreview) {
ZoomIndicator(currentZoom = zoomRatio) // Zoom indicator
ZoomIndicator(currentZoom = zoomRatio)
} else {
Spacer(modifier = Modifier.width(1.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// Camera flip button if (!isGalleryPreview) {
IconButton( // Camera flip button
onClick = { IconButton(
cameraManager.switchCamera() onClick = {
haptics.click() 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 // Toggle controls button
@ -336,66 +350,45 @@ fun CameraScreen(
.systemGestureExclusion(), .systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Zoom presets (only show for back camera) if (isGalleryPreview) {
if (!isFrontCamera) { // Gallery preview mode: Cancel | Apply
ZoomControl( Row(
currentZoom = zoomRatio, verticalAlignment = Alignment.CenterVertically,
minZoom = minZoom, horizontalArrangement = Arrangement.spacedBy(48.dp)
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( // Cancel button
imageVector = Icons.Default.PhotoLibrary, IconButton(
contentDescription = "Pick from gallery", onClick = {
tint = Color.White, galleryBitmap?.recycle()
modifier = Modifier.size(28.dp) 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 // Apply button
CaptureButton( IconButton(
isCapturing = isCapturing, onClick = {
onClick = { val uri = galleryImageUri ?: return@IconButton
if (!isCapturing) { if (!isCapturing) {
isCapturing = true isCapturing = true
haptics.heavyClick() haptics.heavyClick()
scope.launch {
scope.launch { val result = captureHandler.processExistingImage(
val imageCapture = cameraManager.imageCapture imageUri = uri,
if (imageCapture != null) {
val result = captureHandler.capturePhoto(
imageCapture = imageCapture,
executor = cameraManager.getExecutor(),
blurParams = blurParams, blurParams = blurParams,
deviceRotation = currentRotation, location = currentLocation
location = currentLocation,
isFrontCamera = isFrontCamera
) )
when (result) { when (result) {
is SaveResult.Success -> { is SaveResult.Success -> {
haptics.success() haptics.success()
@ -413,20 +406,121 @@ fun CameraScreen(
showSaveError = null 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 for visual symmetry with gallery button
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 ->