Add bottom bar gesture exclusion, dual image save, thumbnail preview, and gallery picker

- Increase bottom padding and add systemGestureExclusion to prevent
  accidental navigation gestures from the capture controls
- Save both original and processed images with shared timestamps
  (TILTSHIFT_* and ORIGINAL_*) via new saveBitmapPair() pipeline
- Show animated thumbnail of last captured photo at bottom-right;
  tap opens the image in the default photo viewer
- Add gallery picker button to process existing photos through the
  tilt-shift pipeline with full EXIF rotation support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-03 22:32:11 +01:00
commit 780a8ab167
3 changed files with 355 additions and 57 deletions

View file

@ -1,15 +1,20 @@
package no.naiv.tiltshift.ui
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.SurfaceTexture
import android.location.Location
import android.net.Uri
import android.opengl.GLSurfaceView
import android.view.Surface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -25,10 +30,14 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipCameraAndroid
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
@ -46,8 +55,11 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.Image
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
@ -97,9 +109,47 @@ fun CameraScreen(
var showSaveError by remember { mutableStateOf<String?>(null) }
var showControls by remember { mutableStateOf(false) }
// Thumbnail state for last captured photo
var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(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
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null && !isCapturing) {
isCapturing = true
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
}
}
isCapturing = false
}
}
}
val zoomRatio by cameraManager.zoomRatio.collectAsState()
val minZoom by cameraManager.minZoomRatio.collectAsState()
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
@ -154,6 +204,7 @@ fun CameraScreen(
onDispose {
cameraManager.release()
renderer?.release()
lastThumbnailBitmap?.recycle()
}
}
@ -281,7 +332,8 @@ fun CameraScreen(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 24.dp),
.padding(bottom = 48.dp)
.systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Zoom presets (only show for back camera)
@ -299,48 +351,98 @@ fun CameraScreen(
Spacer(modifier = Modifier.height(24.dp))
}
// Capture button
CaptureButton(
isCapturing = isCapturing,
onClick = {
if (!isCapturing) {
isCapturing = true
haptics.heavyClick()
// 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)
)
}
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
)
// Capture button
CaptureButton(
isCapturing = isCapturing,
onClick = {
if (!isCapturing) {
isCapturing = true
haptics.heavyClick()
when (result) {
is SaveResult.Success -> {
haptics.success()
showSaveSuccess = true
delay(1500)
showSaveSuccess = false
}
is SaveResult.Error -> {
haptics.error()
showSaveError = result.message
delay(2000)
showSaveError = null
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))
}
}
// Last captured photo thumbnail
LastPhotoThumbnail(
thumbnail = lastThumbnailBitmap,
onTap = {
lastSavedUri?.let { uri ->
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "image/jpeg")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 48.dp, end = 16.dp)
)
// Success indicator
AnimatedVisibility(
visible = showSaveSuccess,
@ -564,3 +666,34 @@ private fun CaptureButton(
)
}
}
/**
* Rounded thumbnail of the last captured photo.
* Tapping opens the image in the default photo viewer.
*/
@Composable
private fun LastPhotoThumbnail(
thumbnail: Bitmap?,
onTap: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = thumbnail != null,
enter = fadeIn() + scaleIn(initialScale = 0.6f),
exit = fadeOut(),
modifier = modifier
) {
thumbnail?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Last captured photo",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(10.dp))
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
.clickable(onClick = onTap)
)
}
}
}