2026-01-28 15:26:41 +01:00
|
|
|
package no.naiv.tiltshift.ui
|
|
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
import android.content.Intent
|
|
|
|
|
import android.graphics.Bitmap
|
2026-01-28 15:26:41 +01:00
|
|
|
import android.graphics.SurfaceTexture
|
|
|
|
|
import android.location.Location
|
2026-03-03 22:32:11 +01:00
|
|
|
import android.net.Uri
|
2026-01-28 15:26:41 +01:00
|
|
|
import android.opengl.GLSurfaceView
|
|
|
|
|
import android.view.Surface
|
|
|
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
|
|
|
import androidx.compose.animation.fadeIn
|
|
|
|
|
import androidx.compose.animation.fadeOut
|
2026-03-03 22:32:11 +01:00
|
|
|
import androidx.compose.animation.scaleIn
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.foundation.background
|
|
|
|
|
import androidx.compose.foundation.border
|
|
|
|
|
import androidx.compose.foundation.clickable
|
2026-03-03 22:32:11 +01:00
|
|
|
import androidx.compose.foundation.systemGestureExclusion
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
|
|
|
import androidx.compose.foundation.layout.Box
|
|
|
|
|
import androidx.compose.foundation.layout.Column
|
|
|
|
|
import androidx.compose.foundation.layout.Row
|
|
|
|
|
import androidx.compose.foundation.layout.Spacer
|
|
|
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
|
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
|
|
|
import androidx.compose.foundation.layout.height
|
|
|
|
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
|
|
|
import androidx.compose.foundation.layout.padding
|
|
|
|
|
import androidx.compose.foundation.layout.size
|
|
|
|
|
import androidx.compose.foundation.layout.statusBarsPadding
|
2026-01-29 11:13:31 +01:00
|
|
|
import androidx.compose.foundation.layout.width
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.foundation.shape.CircleShape
|
2026-01-29 11:13:31 +01:00
|
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
2026-03-03 22:32:11 +01:00
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
|
|
|
import androidx.activity.result.PickVisualMediaRequest
|
|
|
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.material.icons.Icons
|
|
|
|
|
import androidx.compose.material.icons.filled.Check
|
|
|
|
|
import androidx.compose.material.icons.filled.Close
|
2026-01-29 11:13:31 +01:00
|
|
|
import androidx.compose.material.icons.filled.FlipCameraAndroid
|
2026-03-03 22:32:11 +01:00
|
|
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.material3.Icon
|
|
|
|
|
import androidx.compose.material3.IconButton
|
2026-01-29 11:13:31 +01:00
|
|
|
import androidx.compose.material3.Slider
|
|
|
|
|
import androidx.compose.material3.SliderDefaults
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.material3.Text
|
|
|
|
|
import androidx.compose.runtime.Composable
|
|
|
|
|
import androidx.compose.runtime.DisposableEffect
|
|
|
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
|
|
|
import androidx.compose.runtime.collectAsState
|
|
|
|
|
import androidx.compose.runtime.getValue
|
|
|
|
|
import androidx.compose.runtime.mutableStateOf
|
|
|
|
|
import androidx.compose.runtime.remember
|
|
|
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
2026-01-29 16:36:35 +01:00
|
|
|
import androidx.compose.runtime.rememberUpdatedState
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.runtime.setValue
|
|
|
|
|
import androidx.compose.ui.Alignment
|
|
|
|
|
import androidx.compose.ui.Modifier
|
2026-03-03 22:32:11 +01:00
|
|
|
import androidx.compose.foundation.Image
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.ui.draw.clip
|
|
|
|
|
import androidx.compose.ui.graphics.Color
|
2026-03-03 22:32:11 +01:00
|
|
|
import androidx.compose.ui.graphics.asImageBitmap
|
|
|
|
|
import androidx.compose.ui.layout.ContentScale
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.ui.platform.LocalContext
|
|
|
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
|
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
|
import androidx.compose.ui.unit.sp
|
|
|
|
|
import androidx.compose.ui.viewinterop.AndroidView
|
|
|
|
|
import kotlinx.coroutines.delay
|
|
|
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
|
import no.naiv.tiltshift.camera.CameraManager
|
|
|
|
|
import no.naiv.tiltshift.camera.ImageCaptureHandler
|
2026-01-29 11:13:31 +01:00
|
|
|
import no.naiv.tiltshift.effect.BlurMode
|
2026-01-28 15:26:41 +01:00
|
|
|
import no.naiv.tiltshift.effect.BlurParameters
|
|
|
|
|
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
|
|
|
|
import no.naiv.tiltshift.storage.PhotoSaver
|
|
|
|
|
import no.naiv.tiltshift.storage.SaveResult
|
|
|
|
|
import no.naiv.tiltshift.util.HapticFeedback
|
|
|
|
|
import no.naiv.tiltshift.util.LocationProvider
|
|
|
|
|
import no.naiv.tiltshift.util.OrientationDetector
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Main camera screen with tilt-shift controls.
|
|
|
|
|
*/
|
|
|
|
|
@Composable
|
|
|
|
|
fun CameraScreen(
|
|
|
|
|
modifier: Modifier = Modifier
|
|
|
|
|
) {
|
|
|
|
|
val context = LocalContext.current
|
|
|
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
|
|
|
val scope = rememberCoroutineScope()
|
|
|
|
|
|
|
|
|
|
// Camera and effect state
|
|
|
|
|
val cameraManager = remember { CameraManager(context) }
|
|
|
|
|
val photoSaver = remember { PhotoSaver(context) }
|
|
|
|
|
val captureHandler = remember { ImageCaptureHandler(context, photoSaver) }
|
|
|
|
|
val haptics = remember { HapticFeedback(context) }
|
|
|
|
|
val orientationDetector = remember { OrientationDetector(context) }
|
|
|
|
|
val locationProvider = remember { LocationProvider(context) }
|
|
|
|
|
|
|
|
|
|
// State
|
|
|
|
|
var blurParams by remember { mutableStateOf(BlurParameters.DEFAULT) }
|
|
|
|
|
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
|
|
|
|
|
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
|
|
|
|
|
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
|
|
|
|
|
|
|
|
|
|
var isCapturing by remember { mutableStateOf(false) }
|
|
|
|
|
var showSaveSuccess by remember { mutableStateOf(false) }
|
|
|
|
|
var showSaveError by remember { mutableStateOf<String?>(null) }
|
2026-01-29 11:13:31 +01:00
|
|
|
var showControls by remember { mutableStateOf(false) }
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
// Thumbnail state for last captured photo
|
|
|
|
|
var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
|
|
|
|
|
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
|
|
|
|
|
var currentLocation by remember { mutableStateOf<Location?>(null) }
|
|
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
val zoomRatio by cameraManager.zoomRatio.collectAsState()
|
|
|
|
|
val minZoom by cameraManager.minZoomRatio.collectAsState()
|
|
|
|
|
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
|
2026-01-29 11:13:31 +01:00
|
|
|
val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
|
2026-02-27 15:21:38 +01:00
|
|
|
val cameraError by cameraManager.error.collectAsState()
|
|
|
|
|
|
|
|
|
|
// Show camera errors via the existing error UI
|
|
|
|
|
LaunchedEffect(cameraError) {
|
|
|
|
|
cameraError?.let { message ->
|
|
|
|
|
showSaveError = message
|
|
|
|
|
cameraManager.clearError()
|
|
|
|
|
delay(2000)
|
|
|
|
|
showSaveError = null
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
// Collect orientation updates
|
|
|
|
|
LaunchedEffect(Unit) {
|
|
|
|
|
orientationDetector.orientationFlow().collectLatest { rotation ->
|
|
|
|
|
currentRotation = rotation
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect location updates
|
|
|
|
|
LaunchedEffect(Unit) {
|
|
|
|
|
locationProvider.locationFlow().collectLatest { location ->
|
|
|
|
|
currentLocation = location
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update renderer with blur params
|
|
|
|
|
LaunchedEffect(blurParams) {
|
|
|
|
|
renderer?.updateParameters(blurParams)
|
|
|
|
|
glSurfaceView?.requestRender()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 17:03:26 +01:00
|
|
|
// Update renderer when camera switches (front/back)
|
|
|
|
|
LaunchedEffect(isFrontCamera) {
|
|
|
|
|
renderer?.setFrontCamera(isFrontCamera)
|
|
|
|
|
glSurfaceView?.requestRender()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
// Start camera when surface texture is available
|
|
|
|
|
LaunchedEffect(surfaceTexture) {
|
|
|
|
|
surfaceTexture?.let {
|
|
|
|
|
cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cleanup
|
|
|
|
|
DisposableEffect(Unit) {
|
|
|
|
|
onDispose {
|
|
|
|
|
cameraManager.release()
|
|
|
|
|
renderer?.release()
|
2026-03-03 22:32:11 +01:00
|
|
|
lastThumbnailBitmap?.recycle()
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Box(
|
|
|
|
|
modifier = modifier
|
|
|
|
|
.fillMaxSize()
|
|
|
|
|
.background(Color.Black)
|
|
|
|
|
) {
|
|
|
|
|
// OpenGL Surface for camera preview with effect
|
|
|
|
|
AndroidView(
|
|
|
|
|
factory = { ctx ->
|
|
|
|
|
GLSurfaceView(ctx).apply {
|
|
|
|
|
setEGLContextClientVersion(2)
|
|
|
|
|
|
|
|
|
|
val newRenderer = TiltShiftRenderer(ctx) { st ->
|
|
|
|
|
surfaceTexture = st
|
|
|
|
|
}
|
|
|
|
|
renderer = newRenderer
|
|
|
|
|
|
|
|
|
|
setRenderer(newRenderer)
|
|
|
|
|
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
|
|
|
|
|
|
|
|
|
|
glSurfaceView = this
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
modifier = Modifier.fillMaxSize()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Tilt-shift overlay (gesture handling + visualization)
|
|
|
|
|
TiltShiftOverlay(
|
|
|
|
|
params = blurParams,
|
|
|
|
|
onParamsChange = { newParams ->
|
|
|
|
|
blurParams = newParams
|
|
|
|
|
haptics.tick()
|
|
|
|
|
},
|
|
|
|
|
onZoomChange = { zoomDelta ->
|
|
|
|
|
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
|
|
|
|
cameraManager.setZoom(newZoom)
|
|
|
|
|
},
|
|
|
|
|
modifier = Modifier.fillMaxSize()
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-29 11:13:31 +01:00
|
|
|
// Top bar with controls
|
|
|
|
|
Column(
|
2026-01-28 15:26:41 +01:00
|
|
|
modifier = Modifier
|
|
|
|
|
.fillMaxWidth()
|
|
|
|
|
.statusBarsPadding()
|
2026-01-29 11:13:31 +01:00
|
|
|
.padding(16.dp)
|
2026-01-28 15:26:41 +01:00
|
|
|
) {
|
2026-01-29 11:13:31 +01:00
|
|
|
Row(
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
|
|
|
verticalAlignment = Alignment.CenterVertically
|
|
|
|
|
) {
|
|
|
|
|
// Zoom indicator
|
|
|
|
|
ZoomIndicator(currentZoom = zoomRatio)
|
|
|
|
|
|
|
|
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
|
|
|
// Camera flip button
|
|
|
|
|
IconButton(
|
|
|
|
|
onClick = {
|
|
|
|
|
cameraManager.switchCamera()
|
|
|
|
|
haptics.click()
|
|
|
|
|
}
|
|
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.FlipCameraAndroid,
|
|
|
|
|
contentDescription = "Switch Camera",
|
|
|
|
|
tint = Color.White
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-01-29 11:13:31 +01:00
|
|
|
// Toggle controls button
|
|
|
|
|
IconButton(
|
|
|
|
|
onClick = {
|
|
|
|
|
showControls = !showControls
|
|
|
|
|
haptics.tick()
|
|
|
|
|
}
|
|
|
|
|
) {
|
|
|
|
|
Text(
|
|
|
|
|
text = if (showControls) "Hide" else "Ctrl",
|
|
|
|
|
color = Color.White,
|
|
|
|
|
fontSize = 12.sp
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mode toggle
|
|
|
|
|
Row(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.fillMaxWidth()
|
|
|
|
|
.padding(top = 8.dp),
|
|
|
|
|
horizontalArrangement = Arrangement.Center
|
2026-01-28 15:26:41 +01:00
|
|
|
) {
|
2026-01-29 11:13:31 +01:00
|
|
|
ModeToggle(
|
|
|
|
|
currentMode = blurParams.mode,
|
|
|
|
|
onModeChange = { mode ->
|
|
|
|
|
blurParams = blurParams.copy(mode = mode)
|
|
|
|
|
haptics.click()
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 11:13:31 +01:00
|
|
|
// Control panel (sliders)
|
|
|
|
|
AnimatedVisibility(
|
|
|
|
|
visible = showControls,
|
|
|
|
|
enter = fadeIn(),
|
|
|
|
|
exit = fadeOut(),
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.align(Alignment.CenterEnd)
|
|
|
|
|
.padding(end = 16.dp)
|
|
|
|
|
) {
|
|
|
|
|
ControlPanel(
|
|
|
|
|
params = blurParams,
|
|
|
|
|
onParamsChange = { newParams ->
|
|
|
|
|
blurParams = newParams
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
// Bottom controls
|
|
|
|
|
Column(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.align(Alignment.BottomCenter)
|
|
|
|
|
.navigationBarsPadding()
|
2026-03-03 22:32:11 +01:00
|
|
|
.padding(bottom = 48.dp)
|
|
|
|
|
.systemGestureExclusion(),
|
2026-01-28 15:26:41 +01:00
|
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
|
|
|
) {
|
2026-01-29 11:13:31 +01:00
|
|
|
// Zoom presets (only show for back camera)
|
|
|
|
|
if (!isFrontCamera) {
|
|
|
|
|
ZoomControl(
|
|
|
|
|
currentZoom = zoomRatio,
|
|
|
|
|
minZoom = minZoom,
|
|
|
|
|
maxZoom = maxZoom,
|
|
|
|
|
onZoomSelected = { zoom ->
|
|
|
|
|
cameraManager.setZoom(zoom)
|
|
|
|
|
haptics.click()
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-01-29 11:13:31 +01:00
|
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-03 22:32:11 +01:00
|
|
|
isCapturing = false
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-03 22:32:11 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Spacer for visual symmetry with gallery button
|
|
|
|
|
Spacer(modifier = Modifier.size(52.dp))
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
// 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)
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
// Success indicator
|
|
|
|
|
AnimatedVisibility(
|
|
|
|
|
visible = showSaveSuccess,
|
|
|
|
|
enter = fadeIn(),
|
|
|
|
|
exit = fadeOut(),
|
|
|
|
|
modifier = Modifier.align(Alignment.Center)
|
|
|
|
|
) {
|
|
|
|
|
Box(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.size(80.dp)
|
|
|
|
|
.clip(CircleShape)
|
|
|
|
|
.background(Color(0xFF4CAF50)),
|
|
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.Check,
|
|
|
|
|
contentDescription = "Saved",
|
|
|
|
|
tint = Color.White,
|
|
|
|
|
modifier = Modifier.size(48.dp)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Error indicator
|
|
|
|
|
AnimatedVisibility(
|
|
|
|
|
visible = showSaveError != null,
|
|
|
|
|
enter = fadeIn(),
|
|
|
|
|
exit = fadeOut(),
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.align(Alignment.Center)
|
|
|
|
|
.padding(32.dp)
|
|
|
|
|
) {
|
|
|
|
|
Column(
|
|
|
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
|
|
|
modifier = Modifier
|
2026-01-29 11:13:31 +01:00
|
|
|
.clip(RoundedCornerShape(16.dp))
|
2026-01-28 15:26:41 +01:00
|
|
|
.background(Color(0xFFF44336))
|
|
|
|
|
.padding(24.dp)
|
|
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.Close,
|
|
|
|
|
contentDescription = "Error",
|
|
|
|
|
tint = Color.White,
|
|
|
|
|
modifier = Modifier.size(32.dp)
|
|
|
|
|
)
|
|
|
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
|
|
|
Text(
|
|
|
|
|
text = showSaveError ?: "Error",
|
|
|
|
|
color = Color.White,
|
|
|
|
|
fontSize = 14.sp
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 11:13:31 +01:00
|
|
|
/**
|
|
|
|
|
* Mode toggle for Linear / Radial blur modes.
|
|
|
|
|
*/
|
|
|
|
|
@Composable
|
|
|
|
|
private fun ModeToggle(
|
|
|
|
|
currentMode: BlurMode,
|
|
|
|
|
onModeChange: (BlurMode) -> Unit,
|
|
|
|
|
modifier: Modifier = Modifier
|
|
|
|
|
) {
|
|
|
|
|
Row(
|
|
|
|
|
modifier = modifier
|
|
|
|
|
.clip(RoundedCornerShape(20.dp))
|
|
|
|
|
.background(Color(0x80000000))
|
|
|
|
|
.padding(4.dp),
|
|
|
|
|
horizontalArrangement = Arrangement.Center
|
|
|
|
|
) {
|
|
|
|
|
ModeButton(
|
|
|
|
|
text = "Linear",
|
|
|
|
|
isSelected = currentMode == BlurMode.LINEAR,
|
|
|
|
|
onClick = { onModeChange(BlurMode.LINEAR) }
|
|
|
|
|
)
|
|
|
|
|
Spacer(modifier = Modifier.width(4.dp))
|
|
|
|
|
ModeButton(
|
|
|
|
|
text = "Radial",
|
|
|
|
|
isSelected = currentMode == BlurMode.RADIAL,
|
|
|
|
|
onClick = { onModeChange(BlurMode.RADIAL) }
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
private fun ModeButton(
|
|
|
|
|
text: String,
|
|
|
|
|
isSelected: Boolean,
|
|
|
|
|
onClick: () -> Unit
|
|
|
|
|
) {
|
|
|
|
|
Box(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.clip(RoundedCornerShape(16.dp))
|
|
|
|
|
.background(if (isSelected) Color(0xFFFFB300) else Color.Transparent)
|
|
|
|
|
.clickable(onClick = onClick)
|
|
|
|
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
|
|
|
|
Text(
|
|
|
|
|
text = text,
|
|
|
|
|
color = if (isSelected) Color.Black else Color.White,
|
|
|
|
|
fontSize = 14.sp
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Control panel with sliders for blur parameters.
|
|
|
|
|
*/
|
|
|
|
|
@Composable
|
|
|
|
|
private fun ControlPanel(
|
|
|
|
|
params: BlurParameters,
|
|
|
|
|
onParamsChange: (BlurParameters) -> Unit,
|
|
|
|
|
modifier: Modifier = Modifier
|
|
|
|
|
) {
|
2026-01-29 16:36:35 +01:00
|
|
|
// Use rememberUpdatedState to avoid stale closure capture during slider drags
|
|
|
|
|
val currentParams by rememberUpdatedState(params)
|
|
|
|
|
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
|
|
|
|
|
|
2026-01-29 11:13:31 +01:00
|
|
|
Column(
|
|
|
|
|
modifier = modifier
|
|
|
|
|
.width(200.dp)
|
|
|
|
|
.clip(RoundedCornerShape(16.dp))
|
|
|
|
|
.background(Color(0xCC000000))
|
|
|
|
|
.padding(16.dp),
|
|
|
|
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
|
|
|
) {
|
|
|
|
|
// Blur intensity slider
|
|
|
|
|
SliderControl(
|
|
|
|
|
label = "Blur",
|
|
|
|
|
value = params.blurAmount,
|
|
|
|
|
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
|
2026-01-29 16:36:35 +01:00
|
|
|
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
|
2026-01-29 11:13:31 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Falloff slider
|
|
|
|
|
SliderControl(
|
|
|
|
|
label = "Falloff",
|
|
|
|
|
value = params.falloff,
|
|
|
|
|
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
|
2026-01-29 16:36:35 +01:00
|
|
|
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
|
2026-01-29 11:13:31 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Aspect ratio slider (radial mode only)
|
|
|
|
|
if (params.mode == BlurMode.RADIAL) {
|
|
|
|
|
SliderControl(
|
|
|
|
|
label = "Shape",
|
|
|
|
|
value = params.aspectRatio,
|
|
|
|
|
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
|
2026-01-29 16:36:35 +01:00
|
|
|
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
2026-01-29 11:13:31 +01:00
|
|
|
)
|
|
|
|
|
}
|
2026-02-27 15:50:53 +01:00
|
|
|
|
2026-01-29 11:13:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
private fun SliderControl(
|
|
|
|
|
label: String,
|
|
|
|
|
value: Float,
|
|
|
|
|
valueRange: ClosedFloatingPointRange<Float>,
|
|
|
|
|
onValueChange: (Float) -> Unit
|
|
|
|
|
) {
|
|
|
|
|
Column {
|
|
|
|
|
Row(
|
|
|
|
|
modifier = Modifier.fillMaxWidth(),
|
|
|
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
|
|
|
) {
|
|
|
|
|
Text(
|
|
|
|
|
text = label,
|
|
|
|
|
color = Color.White,
|
|
|
|
|
fontSize = 12.sp
|
|
|
|
|
)
|
|
|
|
|
Text(
|
|
|
|
|
text = "${(value * 100).toInt()}%",
|
|
|
|
|
color = Color.White.copy(alpha = 0.7f),
|
|
|
|
|
fontSize = 12.sp
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
Slider(
|
|
|
|
|
value = value,
|
|
|
|
|
onValueChange = onValueChange,
|
|
|
|
|
valueRange = valueRange,
|
|
|
|
|
colors = SliderDefaults.colors(
|
|
|
|
|
thumbColor = Color(0xFFFFB300),
|
|
|
|
|
activeTrackColor = Color(0xFFFFB300),
|
|
|
|
|
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
|
|
|
|
),
|
|
|
|
|
modifier = Modifier.height(24.dp)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
/**
|
|
|
|
|
* Capture button with animation for capturing state.
|
|
|
|
|
*/
|
|
|
|
|
@Composable
|
|
|
|
|
private fun CaptureButton(
|
|
|
|
|
isCapturing: Boolean,
|
|
|
|
|
onClick: () -> Unit,
|
|
|
|
|
modifier: Modifier = Modifier
|
|
|
|
|
) {
|
|
|
|
|
val outerSize = 72.dp
|
|
|
|
|
val innerSize = if (isCapturing) 48.dp else 60.dp
|
|
|
|
|
|
|
|
|
|
Box(
|
|
|
|
|
modifier = modifier
|
|
|
|
|
.size(outerSize)
|
|
|
|
|
.clip(CircleShape)
|
|
|
|
|
.border(4.dp, Color.White, CircleShape)
|
|
|
|
|
.clickable(enabled = !isCapturing, onClick = onClick),
|
|
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
|
|
|
|
Box(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.size(innerSize)
|
|
|
|
|
.clip(CircleShape)
|
|
|
|
|
.background(if (isCapturing) Color(0xFFFFB300) else Color.White)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-03 22:32:11 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|