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
|
2026-01-28 15:26:41 +01:00
|
|
|
import android.graphics.SurfaceTexture
|
|
|
|
|
import android.opengl.GLSurfaceView
|
2026-03-05 11:56:29 +01:00
|
|
|
import android.util.Log
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
|
|
|
import androidx.compose.animation.fadeIn
|
|
|
|
|
import androidx.compose.animation.fadeOut
|
2026-03-18 17:50:30 +01:00
|
|
|
import androidx.compose.foundation.Image
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.foundation.background
|
|
|
|
|
import androidx.compose.foundation.border
|
|
|
|
|
import androidx.compose.foundation.clickable
|
|
|
|
|
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-18 17:50:30 +01:00
|
|
|
import androidx.compose.foundation.systemGestureExclusion
|
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-05 13:50:33 +01:00
|
|
|
import androidx.compose.material.icons.filled.LocationOff
|
|
|
|
|
import androidx.compose.material.icons.filled.LocationOn
|
2026-03-18 17:50:30 +01:00
|
|
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
2026-03-05 12:23:43 +01:00
|
|
|
import androidx.compose.material.icons.filled.Tune
|
|
|
|
|
import androidx.compose.material3.CircularProgressIndicator
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.material3.Icon
|
|
|
|
|
import androidx.compose.material3.IconButton
|
|
|
|
|
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
|
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
|
|
|
|
|
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
|
2026-03-05 12:23:43 +01:00
|
|
|
import androidx.compose.ui.semantics.LiveRegionMode
|
2026-03-18 17:50:30 +01:00
|
|
|
import androidx.compose.ui.semantics.Role
|
2026-03-05 12:23:43 +01:00
|
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
|
|
|
import androidx.compose.ui.semantics.liveRegion
|
|
|
|
|
import androidx.compose.ui.semantics.semantics
|
|
|
|
|
import androidx.compose.ui.semantics.stateDescription
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
|
import androidx.compose.ui.unit.sp
|
|
|
|
|
import androidx.compose.ui.viewinterop.AndroidView
|
2026-03-05 13:58:17 +01:00
|
|
|
import androidx.lifecycle.Lifecycle
|
|
|
|
|
import androidx.lifecycle.LifecycleEventObserver
|
2026-03-05 12:23:43 +01:00
|
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
|
|
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
2026-01-28 15:26:41 +01:00
|
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
|
|
|
import no.naiv.tiltshift.effect.BlurParameters
|
|
|
|
|
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
2026-03-05 12:23:43 +01:00
|
|
|
import no.naiv.tiltshift.ui.theme.AppColors
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Main camera screen with tilt-shift controls.
|
2026-03-05 12:23:43 +01:00
|
|
|
* Uses CameraViewModel to survive configuration changes.
|
2026-01-28 15:26:41 +01:00
|
|
|
*/
|
|
|
|
|
@Composable
|
|
|
|
|
fun CameraScreen(
|
2026-03-05 12:23:43 +01:00
|
|
|
modifier: Modifier = Modifier,
|
|
|
|
|
viewModel: CameraViewModel = viewModel()
|
2026-01-28 15:26:41 +01:00
|
|
|
) {
|
|
|
|
|
val context = LocalContext.current
|
|
|
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
2026-03-05 12:23:43 +01:00
|
|
|
|
|
|
|
|
// GL state (view-layer, not in ViewModel)
|
2026-01-28 15:26:41 +01:00
|
|
|
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
|
|
|
|
|
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
|
|
|
|
|
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
|
|
|
|
|
|
2026-03-05 12:23:43 +01:00
|
|
|
// Collect ViewModel state
|
|
|
|
|
val blurParams by viewModel.blurParams.collectAsState()
|
|
|
|
|
val isCapturing by viewModel.isCapturing.collectAsState()
|
|
|
|
|
val isProcessing by viewModel.isProcessing.collectAsState()
|
|
|
|
|
val showSaveSuccess by viewModel.showSaveSuccess.collectAsState()
|
|
|
|
|
val showSaveError by viewModel.showSaveError.collectAsState()
|
|
|
|
|
val showControls by viewModel.showControls.collectAsState()
|
|
|
|
|
val lastSavedUri by viewModel.lastSavedUri.collectAsState()
|
|
|
|
|
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
|
2026-03-05 12:51:26 +01:00
|
|
|
val galleryPreviewBitmap by viewModel.galleryPreviewBitmap.collectAsState()
|
2026-03-05 13:50:33 +01:00
|
|
|
val geotagEnabled by viewModel.geotagEnabled.collectAsState()
|
2026-03-05 12:51:26 +01:00
|
|
|
val isGalleryPreview = galleryPreviewBitmap != null
|
2026-03-05 11:46:05 +01:00
|
|
|
|
2026-03-05 12:23:43 +01:00
|
|
|
val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState()
|
|
|
|
|
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
|
|
|
|
|
val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState()
|
|
|
|
|
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
|
2026-03-05 13:58:17 +01:00
|
|
|
val previewResolution by viewModel.cameraManager.previewResolution.collectAsState()
|
2026-03-05 12:23:43 +01:00
|
|
|
val cameraError by viewModel.cameraManager.error.collectAsState()
|
2026-05-07 16:31:43 +02:00
|
|
|
val currentRotation by viewModel.currentRotation.collectAsState()
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-03-05 12:23:43 +01:00
|
|
|
// Gallery picker
|
2026-03-03 22:32:11 +01:00
|
|
|
val galleryLauncher = rememberLauncherForActivityResult(
|
|
|
|
|
contract = ActivityResultContracts.PickVisualMedia()
|
|
|
|
|
) { uri ->
|
2026-03-05 12:23:43 +01:00
|
|
|
if (uri != null) {
|
|
|
|
|
viewModel.loadGalleryImage(uri)
|
2026-03-03 22:32:11 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 12:23:43 +01:00
|
|
|
// Show camera errors
|
2026-02-27 15:21:38 +01:00
|
|
|
LaunchedEffect(cameraError) {
|
|
|
|
|
cameraError?.let { message ->
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.showCameraError(message)
|
|
|
|
|
viewModel.cameraManager.clearError()
|
2026-02-27 15:21:38 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
// Collect orientation updates
|
|
|
|
|
LaunchedEffect(Unit) {
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.orientationDetector.orientationFlow().collectLatest { rotation ->
|
|
|
|
|
viewModel.updateRotation(rotation)
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect location updates
|
|
|
|
|
LaunchedEffect(Unit) {
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.locationProvider.locationFlow().collectLatest { location ->
|
|
|
|
|
viewModel.updateLocation(location)
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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-03-05 13:58:17 +01:00
|
|
|
// Update renderer with camera preview resolution for crop-to-fill
|
|
|
|
|
LaunchedEffect(previewResolution) {
|
|
|
|
|
if (previewResolution.width > 0) {
|
|
|
|
|
renderer?.setCameraResolution(previewResolution.width, previewResolution.height)
|
|
|
|
|
glSurfaceView?.requestRender()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 16:31:43 +02:00
|
|
|
// Forward device rotation to renderer (aspect math) and CameraX (target rotation)
|
|
|
|
|
LaunchedEffect(currentRotation, renderer) {
|
|
|
|
|
renderer?.setDisplayRotation(currentRotation)
|
|
|
|
|
viewModel.cameraManager.setTargetRotation(currentRotation)
|
|
|
|
|
glSurfaceView?.requestRender()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
// Start camera when surface texture is available
|
|
|
|
|
LaunchedEffect(surfaceTexture) {
|
|
|
|
|
surfaceTexture?.let {
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 13:44:12 +01:00
|
|
|
// Pause/resume GLSurfaceView when entering/leaving gallery preview
|
|
|
|
|
LaunchedEffect(isGalleryPreview) {
|
|
|
|
|
if (isGalleryPreview) {
|
|
|
|
|
glSurfaceView?.onPause()
|
2026-03-05 13:58:17 +01:00
|
|
|
} else if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
2026-03-05 13:44:12 +01:00
|
|
|
glSurfaceView?.onResume()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 13:58:17 +01:00
|
|
|
// Tie GLSurfaceView lifecycle to Activity lifecycle to prevent background rendering
|
|
|
|
|
val currentIsGalleryPreview by rememberUpdatedState(isGalleryPreview)
|
|
|
|
|
DisposableEffect(lifecycleOwner) {
|
|
|
|
|
val observer = LifecycleEventObserver { _, event ->
|
|
|
|
|
when (event) {
|
|
|
|
|
Lifecycle.Event.ON_RESUME -> {
|
|
|
|
|
if (!currentIsGalleryPreview) {
|
|
|
|
|
glSurfaceView?.onResume()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Lifecycle.Event.ON_PAUSE -> glSurfaceView?.onPause()
|
|
|
|
|
else -> {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
lifecycleOwner.lifecycle.addObserver(observer)
|
2026-01-28 15:26:41 +01:00
|
|
|
onDispose {
|
2026-03-05 13:58:17 +01:00
|
|
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
2026-03-05 13:44:12 +01:00
|
|
|
glSurfaceView?.queueEvent { renderer?.release() }
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Box(
|
|
|
|
|
modifier = modifier
|
|
|
|
|
.fillMaxSize()
|
|
|
|
|
.background(Color.Black)
|
|
|
|
|
) {
|
2026-03-05 11:46:05 +01:00
|
|
|
// Main view: gallery preview image or camera GL surface
|
|
|
|
|
if (isGalleryPreview) {
|
2026-03-05 12:51:26 +01:00
|
|
|
galleryPreviewBitmap?.let { bmp ->
|
2026-03-05 11:46:05 +01:00
|
|
|
Image(
|
|
|
|
|
bitmap = bmp.asImageBitmap(),
|
2026-03-05 12:51:26 +01:00
|
|
|
contentDescription = "Gallery image preview with tilt-shift effect. Adjust parameters then tap Apply.",
|
2026-03-05 11:46:05 +01:00
|
|
|
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)
|
|
|
|
|
|
2026-03-05 13:58:17 +01:00
|
|
|
val view = this
|
|
|
|
|
val newRenderer = TiltShiftRenderer(
|
|
|
|
|
context = ctx,
|
|
|
|
|
onSurfaceTextureAvailable = { st -> surfaceTexture = st },
|
|
|
|
|
onFrameAvailable = { view.requestRender() }
|
|
|
|
|
)
|
2026-03-05 11:46:05 +01:00
|
|
|
renderer = newRenderer
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-03-05 11:46:05 +01:00
|
|
|
setRenderer(newRenderer)
|
2026-03-05 13:58:17 +01:00
|
|
|
renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-03-05 11:46:05 +01:00
|
|
|
glSurfaceView = this
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
modifier = Modifier.fillMaxSize()
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
// Tilt-shift overlay (gesture handling + visualization)
|
|
|
|
|
TiltShiftOverlay(
|
|
|
|
|
params = blurParams,
|
|
|
|
|
onParamsChange = { newParams ->
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.updateBlurParams(newParams)
|
2026-01-28 15:26:41 +01:00
|
|
|
},
|
|
|
|
|
onZoomChange = { zoomDelta ->
|
2026-03-05 11:46:05 +01:00
|
|
|
if (!isGalleryPreview) {
|
|
|
|
|
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.cameraManager.setZoom(newZoom)
|
2026-03-05 11:46:05 +01:00
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
},
|
|
|
|
|
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
|
|
|
|
|
) {
|
2026-03-05 11:46:05 +01:00
|
|
|
if (!isGalleryPreview) {
|
|
|
|
|
ZoomIndicator(currentZoom = zoomRatio)
|
|
|
|
|
} else {
|
|
|
|
|
Spacer(modifier = Modifier.width(1.dp))
|
|
|
|
|
}
|
2026-01-29 11:13:31 +01:00
|
|
|
|
|
|
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
2026-03-05 13:50:33 +01:00
|
|
|
// GPS geotagging toggle
|
|
|
|
|
IconButton(
|
|
|
|
|
onClick = {
|
|
|
|
|
viewModel.toggleGeotag()
|
|
|
|
|
viewModel.haptics.tick()
|
|
|
|
|
}
|
|
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = if (geotagEnabled) Icons.Default.LocationOn else Icons.Default.LocationOff,
|
|
|
|
|
contentDescription = if (geotagEnabled) "Disable GPS geotagging" else "Enable GPS geotagging",
|
|
|
|
|
tint = if (geotagEnabled) Color.White else Color.White.copy(alpha = 0.5f)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 11:46:05 +01:00
|
|
|
if (!isGalleryPreview) {
|
|
|
|
|
// Camera flip button
|
|
|
|
|
IconButton(
|
|
|
|
|
onClick = {
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.cameraManager.switchCamera()
|
|
|
|
|
viewModel.haptics.click()
|
2026-03-05 11:46:05 +01:00
|
|
|
}
|
|
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.FlipCameraAndroid,
|
2026-03-05 12:23:43 +01:00
|
|
|
contentDescription = "Switch between front and back camera",
|
2026-03-05 11:46:05 +01:00
|
|
|
tint = Color.White
|
|
|
|
|
)
|
2026-01-29 11:13:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-03-05 12:23:43 +01:00
|
|
|
// Toggle controls button (tune icon instead of cryptic "Ctrl")
|
2026-01-29 11:13:31 +01:00
|
|
|
IconButton(
|
|
|
|
|
onClick = {
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.toggleControls()
|
|
|
|
|
viewModel.haptics.tick()
|
|
|
|
|
},
|
|
|
|
|
modifier = Modifier.semantics {
|
|
|
|
|
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
|
2026-01-29 11:13:31 +01:00
|
|
|
}
|
|
|
|
|
) {
|
2026-03-05 12:23:43 +01:00
|
|
|
Icon(
|
|
|
|
|
imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune,
|
|
|
|
|
contentDescription = if (showControls) "Hide blur controls" else "Show blur controls",
|
|
|
|
|
tint = Color.White
|
2026-01-29 11:13:31 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 ->
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.updateBlurParams(blurParams.copy(mode = mode))
|
|
|
|
|
viewModel.haptics.click()
|
2026-01-29 11:13:31 +01:00
|
|
|
}
|
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 ->
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.updateBlurParams(newParams)
|
|
|
|
|
},
|
|
|
|
|
onReset = { viewModel.resetBlurParams() }
|
2026-01-29 11:13:31 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
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-03-05 11:46:05 +01:00
|
|
|
if (isGalleryPreview) {
|
2026-03-05 12:23:43 +01:00
|
|
|
// Gallery preview mode: Cancel | Apply (matched layout to camera mode)
|
2026-03-05 11:46:05 +01:00
|
|
|
Row(
|
|
|
|
|
verticalAlignment = Alignment.CenterVertically,
|
2026-03-05 12:23:43 +01:00
|
|
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
2026-03-03 22:32:11 +01:00
|
|
|
) {
|
2026-03-05 12:23:43 +01:00
|
|
|
// Cancel button (same 52dp as gallery button for layout consistency)
|
2026-03-05 11:46:05 +01:00
|
|
|
IconButton(
|
2026-03-05 12:23:43 +01:00
|
|
|
onClick = { viewModel.cancelGalleryPreview() },
|
2026-03-05 11:46:05 +01:00
|
|
|
modifier = Modifier
|
2026-03-05 12:23:43 +01:00
|
|
|
.size(52.dp)
|
2026-03-05 11:46:05 +01:00
|
|
|
.clip(CircleShape)
|
2026-03-05 12:23:43 +01:00
|
|
|
.background(AppColors.OverlayDark)
|
|
|
|
|
.semantics { contentDescription = "Cancel gallery import" }
|
2026-03-05 11:46:05 +01:00
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.Close,
|
2026-03-05 12:23:43 +01:00
|
|
|
contentDescription = null,
|
2026-03-05 11:46:05 +01:00
|
|
|
tint = Color.White,
|
|
|
|
|
modifier = Modifier.size(28.dp)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-03-03 22:32:11 +01:00
|
|
|
|
2026-03-05 12:23:43 +01:00
|
|
|
// Apply button (same 72dp as capture button for layout consistency)
|
|
|
|
|
Box(
|
2026-03-05 11:46:05 +01:00
|
|
|
modifier = Modifier
|
2026-03-05 12:23:43 +01:00
|
|
|
.size(72.dp)
|
2026-03-05 11:46:05 +01:00
|
|
|
.clip(CircleShape)
|
2026-03-05 12:23:43 +01:00
|
|
|
.border(4.dp, Color.White, CircleShape)
|
|
|
|
|
.clickable(
|
|
|
|
|
enabled = !isCapturing,
|
|
|
|
|
role = Role.Button,
|
|
|
|
|
onClick = { viewModel.applyGalleryEffect() }
|
|
|
|
|
)
|
|
|
|
|
.semantics {
|
|
|
|
|
contentDescription = "Apply tilt-shift effect to gallery image"
|
|
|
|
|
if (isCapturing) stateDescription = "Processing"
|
|
|
|
|
},
|
|
|
|
|
contentAlignment = Alignment.Center
|
2026-03-05 11:46:05 +01:00
|
|
|
) {
|
2026-03-05 12:23:43 +01:00
|
|
|
Box(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.size(if (isCapturing) 48.dp else 60.dp)
|
|
|
|
|
.clip(CircleShape)
|
|
|
|
|
.background(if (isCapturing) AppColors.Accent.copy(alpha = 0.5f) else AppColors.Accent),
|
|
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
|
|
|
|
if (isProcessing) {
|
|
|
|
|
CircularProgressIndicator(
|
|
|
|
|
modifier = Modifier.size(24.dp),
|
|
|
|
|
color = Color.Black,
|
|
|
|
|
strokeWidth = 3.dp
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.Check,
|
|
|
|
|
contentDescription = null,
|
|
|
|
|
tint = Color.Black,
|
|
|
|
|
modifier = Modifier.size(28.dp)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 11:46:05 +01:00
|
|
|
}
|
2026-03-05 12:23:43 +01:00
|
|
|
|
|
|
|
|
// Spacer for visual symmetry
|
|
|
|
|
Spacer(modifier = Modifier.size(52.dp))
|
2026-03-05 11:46:05 +01:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Camera mode: Zoom presets + Gallery | Capture | Spacer
|
|
|
|
|
if (!isFrontCamera) {
|
|
|
|
|
ZoomControl(
|
|
|
|
|
currentZoom = zoomRatio,
|
|
|
|
|
minZoom = minZoom,
|
|
|
|
|
maxZoom = maxZoom,
|
|
|
|
|
onZoomSelected = { zoom ->
|
2026-03-05 12:23:43 +01:00
|
|
|
viewModel.cameraManager.setZoom(zoom)
|
|
|
|
|
viewModel.haptics.click()
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
2026-03-05 11:46:05 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Row(
|
|
|
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
|
|
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
|
|
|
|
) {
|
2026-03-05 12:23:43 +01:00
|
|
|
// Gallery picker button (with background for discoverability)
|
2026-03-05 11:46:05 +01:00
|
|
|
IconButton(
|
|
|
|
|
onClick = {
|
|
|
|
|
if (!isCapturing) {
|
|
|
|
|
galleryLauncher.launch(
|
|
|
|
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
enabled = !isCapturing,
|
2026-03-05 12:23:43 +01:00
|
|
|
modifier = Modifier
|
|
|
|
|
.size(52.dp)
|
|
|
|
|
.clip(CircleShape)
|
|
|
|
|
.background(AppColors.OverlayDark)
|
2026-03-05 11:46:05 +01:00
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.PhotoLibrary,
|
2026-03-05 12:23:43 +01:00
|
|
|
contentDescription = "Pick image from gallery",
|
2026-03-05 11:46:05 +01:00
|
|
|
tint = Color.White,
|
|
|
|
|
modifier = Modifier.size(28.dp)
|
|
|
|
|
)
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
2026-03-03 22:32:11 +01:00
|
|
|
|
2026-03-05 11:46:05 +01:00
|
|
|
// Capture button
|
|
|
|
|
CaptureButton(
|
|
|
|
|
isCapturing = isCapturing,
|
2026-03-05 12:23:43 +01:00
|
|
|
isProcessing = isProcessing,
|
|
|
|
|
onClick = { viewModel.capturePhoto() }
|
2026-03-05 11:46:05 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Spacer for visual symmetry with gallery button
|
|
|
|
|
Spacer(modifier = Modifier.size(52.dp))
|
|
|
|
|
}
|
2026-03-03 22:32:11 +01:00
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 11:46:05 +01:00
|
|
|
// Last captured photo thumbnail (hidden in gallery preview mode)
|
|
|
|
|
if (!isGalleryPreview) LastPhotoThumbnail(
|
2026-03-03 22:32:11 +01:00
|
|
|
thumbnail = lastThumbnailBitmap,
|
|
|
|
|
onTap = {
|
|
|
|
|
lastSavedUri?.let { uri ->
|
2026-03-05 11:56:29 +01:00
|
|
|
try {
|
|
|
|
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
|
|
|
setDataAndType(uri, "image/jpeg")
|
|
|
|
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
|
|
|
}
|
|
|
|
|
context.startActivity(intent)
|
|
|
|
|
} catch (e: android.content.ActivityNotFoundException) {
|
|
|
|
|
Log.w("CameraScreen", "No activity found to view image", e)
|
2026-03-05 13:44:12 +01:00
|
|
|
viewModel.showCameraError("No app available to view photos")
|
2026-03-03 22:32:11 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.align(Alignment.BottomEnd)
|
|
|
|
|
.navigationBarsPadding()
|
|
|
|
|
.padding(bottom = 48.dp, end = 16.dp)
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-05 12:23:43 +01:00
|
|
|
// Success indicator (announced to accessibility)
|
2026-01-28 15:26:41 +01:00
|
|
|
AnimatedVisibility(
|
|
|
|
|
visible = showSaveSuccess,
|
|
|
|
|
enter = fadeIn(),
|
|
|
|
|
exit = fadeOut(),
|
2026-03-05 12:23:43 +01:00
|
|
|
modifier = Modifier
|
|
|
|
|
.align(Alignment.Center)
|
|
|
|
|
.semantics { liveRegion = LiveRegionMode.Polite }
|
2026-01-28 15:26:41 +01:00
|
|
|
) {
|
|
|
|
|
Box(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.size(80.dp)
|
|
|
|
|
.clip(CircleShape)
|
2026-03-05 12:23:43 +01:00
|
|
|
.background(AppColors.Success),
|
2026-01-28 15:26:41 +01:00
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.Check,
|
2026-03-05 12:23:43 +01:00
|
|
|
contentDescription = "Photo saved successfully",
|
2026-01-28 15:26:41 +01:00
|
|
|
tint = Color.White,
|
|
|
|
|
modifier = Modifier.size(48.dp)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 12:23:43 +01:00
|
|
|
// Error indicator (announced to accessibility)
|
2026-01-28 15:26:41 +01:00
|
|
|
AnimatedVisibility(
|
|
|
|
|
visible = showSaveError != null,
|
|
|
|
|
enter = fadeIn(),
|
|
|
|
|
exit = fadeOut(),
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.align(Alignment.Center)
|
|
|
|
|
.padding(32.dp)
|
2026-03-05 12:23:43 +01:00
|
|
|
.semantics { liveRegion = LiveRegionMode.Assertive }
|
2026-01-28 15:26:41 +01:00
|
|
|
) {
|
|
|
|
|
Column(
|
|
|
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
|
|
|
modifier = Modifier
|
2026-01-29 11:13:31 +01:00
|
|
|
.clip(RoundedCornerShape(16.dp))
|
2026-03-05 12:23:43 +01:00
|
|
|
.background(AppColors.Error)
|
2026-01-28 15:26:41 +01:00
|
|
|
.padding(24.dp)
|
|
|
|
|
) {
|
|
|
|
|
Icon(
|
|
|
|
|
imageVector = Icons.Default.Close,
|
2026-03-05 12:23:43 +01:00
|
|
|
contentDescription = null,
|
2026-01-28 15:26:41 +01:00
|
|
|
tint = Color.White,
|
|
|
|
|
modifier = Modifier.size(32.dp)
|
|
|
|
|
)
|
|
|
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
|
|
|
Text(
|
|
|
|
|
text = showSaveError ?: "Error",
|
|
|
|
|
color = Color.White,
|
2026-03-05 12:23:43 +01:00
|
|
|
fontSize = 14.sp,
|
|
|
|
|
maxLines = 3
|
2026-01-28 15:26:41 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 12:23:43 +01:00
|
|
|
|
|
|
|
|
// Processing overlay
|
|
|
|
|
AnimatedVisibility(
|
|
|
|
|
visible = isProcessing,
|
|
|
|
|
enter = fadeIn(),
|
|
|
|
|
exit = fadeOut(),
|
|
|
|
|
modifier = Modifier.align(Alignment.Center)
|
|
|
|
|
) {
|
|
|
|
|
if (!showSaveSuccess) {
|
|
|
|
|
Box(
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.size(80.dp)
|
|
|
|
|
.clip(CircleShape)
|
|
|
|
|
.background(AppColors.OverlayDark),
|
|
|
|
|
contentAlignment = Alignment.Center
|
|
|
|
|
) {
|
|
|
|
|
CircularProgressIndicator(
|
|
|
|
|
color = AppColors.Accent,
|
|
|
|
|
strokeWidth = 3.dp,
|
|
|
|
|
modifier = Modifier.size(36.dp)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|