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:
parent
780a8ab167
commit
5d80dcfcbe
2 changed files with 228 additions and 119 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue