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:
parent
7abb2ea5a0
commit
780a8ab167
3 changed files with 355 additions and 57 deletions
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue