tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt

817 lines
30 KiB
Kotlin
Raw Normal View History

package no.naiv.tiltshift.ui
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.SurfaceTexture
import android.opengl.GLSurfaceView
import android.util.Log
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
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
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.foundation.Image
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.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
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.rememberUpdatedState
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
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collectLatest
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer
import no.naiv.tiltshift.ui.theme.AppColors
/**
* Main camera screen with tilt-shift controls.
* Uses CameraViewModel to survive configuration changes.
*/
@Composable
fun CameraScreen(
modifier: Modifier = Modifier,
viewModel: CameraViewModel = viewModel()
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// GL state (view-layer, not in ViewModel)
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
// 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()
val galleryPreviewBitmap by viewModel.galleryPreviewBitmap.collectAsState()
val isGalleryPreview = galleryPreviewBitmap != null
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()
val cameraError by viewModel.cameraManager.error.collectAsState()
// Gallery picker
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) {
viewModel.loadGalleryImage(uri)
}
}
// Show camera errors
LaunchedEffect(cameraError) {
cameraError?.let { message ->
viewModel.showCameraError(message)
viewModel.cameraManager.clearError()
}
}
// Collect orientation updates
LaunchedEffect(Unit) {
viewModel.orientationDetector.orientationFlow().collectLatest { rotation ->
viewModel.updateRotation(rotation)
}
}
// Collect location updates
LaunchedEffect(Unit) {
viewModel.locationProvider.locationFlow().collectLatest { location ->
viewModel.updateLocation(location)
}
}
// Update renderer with blur params
LaunchedEffect(blurParams) {
renderer?.updateParameters(blurParams)
glSurfaceView?.requestRender()
}
// Update renderer when camera switches (front/back)
LaunchedEffect(isFrontCamera) {
renderer?.setFrontCamera(isFrontCamera)
glSurfaceView?.requestRender()
}
// Start camera when surface texture is available
LaunchedEffect(surfaceTexture) {
surfaceTexture?.let {
viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
}
}
// Cleanup GL resources (ViewModel handles its own cleanup in onCleared)
DisposableEffect(Unit) {
onDispose {
renderer?.release()
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
) {
// Main view: gallery preview image or camera GL surface
if (isGalleryPreview) {
galleryPreviewBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Gallery image preview with tilt-shift effect. Adjust parameters then tap Apply.",
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)
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 ->
viewModel.updateBlurParams(newParams)
},
onZoomChange = { zoomDelta ->
if (!isGalleryPreview) {
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
viewModel.cameraManager.setZoom(newZoom)
}
},
modifier = Modifier.fillMaxSize()
)
// Top bar with controls
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (!isGalleryPreview) {
ZoomIndicator(currentZoom = zoomRatio)
} else {
Spacer(modifier = Modifier.width(1.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (!isGalleryPreview) {
// Camera flip button
IconButton(
onClick = {
viewModel.cameraManager.switchCamera()
viewModel.haptics.click()
}
) {
Icon(
imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch between front and back camera",
tint = Color.White
)
}
}
// Toggle controls button (tune icon instead of cryptic "Ctrl")
IconButton(
onClick = {
viewModel.toggleControls()
viewModel.haptics.tick()
},
modifier = Modifier.semantics {
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
}
) {
Icon(
imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune,
contentDescription = if (showControls) "Hide blur controls" else "Show blur controls",
tint = Color.White
)
}
}
}
// Mode toggle
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.Center
) {
ModeToggle(
currentMode = blurParams.mode,
onModeChange = { mode ->
viewModel.updateBlurParams(blurParams.copy(mode = mode))
viewModel.haptics.click()
}
)
}
}
// Control panel (sliders)
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
) {
ControlPanel(
params = blurParams,
onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams)
},
onReset = { viewModel.resetBlurParams() }
)
}
// Bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 48.dp)
.systemGestureExclusion(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isGalleryPreview) {
// Gallery preview mode: Cancel | Apply (matched layout to camera mode)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Cancel button (same 52dp as gallery button for layout consistency)
IconButton(
onClick = { viewModel.cancelGalleryPreview() },
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
.semantics { contentDescription = "Cancel gallery import" }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Apply button (same 72dp as capture button for layout consistency)
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.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
) {
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)
)
}
}
}
// Spacer for visual symmetry
Spacer(modifier = Modifier.size(52.dp))
}
} else {
// Camera mode: Zoom presets + Gallery | Capture | Spacer
if (!isFrontCamera) {
ZoomControl(
currentZoom = zoomRatio,
minZoom = minZoom,
maxZoom = maxZoom,
onZoomSelected = { zoom ->
viewModel.cameraManager.setZoom(zoom)
viewModel.haptics.click()
}
)
Spacer(modifier = Modifier.height(24.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Gallery picker button (with background for discoverability)
IconButton(
onClick = {
if (!isCapturing) {
galleryLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
},
enabled = !isCapturing,
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
) {
Icon(
imageVector = Icons.Default.PhotoLibrary,
contentDescription = "Pick image from gallery",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Capture button
CaptureButton(
isCapturing = isCapturing,
isProcessing = isProcessing,
onClick = { viewModel.capturePhoto() }
)
// Spacer for visual symmetry with gallery button
Spacer(modifier = Modifier.size(52.dp))
}
}
}
// Last captured photo thumbnail (hidden in gallery preview mode)
if (!isGalleryPreview) LastPhotoThumbnail(
thumbnail = lastThumbnailBitmap,
onTap = {
lastSavedUri?.let { uri ->
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)
}
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 48.dp, end = 16.dp)
)
// Success indicator (announced to accessibility)
AnimatedVisibility(
visible = showSaveSuccess,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.Center)
.semantics { liveRegion = LiveRegionMode.Polite }
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(AppColors.Success),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Photo saved successfully",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
}
// Error indicator (announced to accessibility)
AnimatedVisibility(
visible = showSaveError != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp)
.semantics { liveRegion = LiveRegionMode.Assertive }
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(AppColors.Error)
.padding(24.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = showSaveError ?: "Error",
color = Color.White,
fontSize = 14.sp,
maxLines = 3
)
}
}
// 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)
)
}
}
}
}
}
/**
* 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(AppColors.OverlayDark)
.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) AppColors.Accent else Color.Transparent)
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics {
stateDescription = if (isSelected) "Selected" else "Not selected"
contentDescription = "$text blur mode"
},
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = if (isSelected) Color.Black else Color.White,
fontSize = 14.sp
)
}
}
/**
* Control panel with sliders for blur parameters.
* Includes position/size/angle sliders as gesture alternatives for accessibility.
*/
@Composable
private fun ControlPanel(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
val currentParams by rememberUpdatedState(params)
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
Column(
modifier = modifier
.width(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(AppColors.OverlayDarker)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Reset button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onReset,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = "Reset all parameters to defaults",
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
}
// Blur intensity slider
SliderControl(
label = "Blur",
value = params.blurAmount,
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
)
// Falloff slider
SliderControl(
label = "Falloff",
value = params.falloff,
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
)
// Size slider (gesture alternative for pinch-to-resize)
SliderControl(
label = "Size",
value = params.size,
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
)
// Aspect ratio slider (radial mode only)
if (params.mode == BlurMode.RADIAL) {
SliderControl(
label = "Shape",
value = params.aspectRatio,
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
formatValue = { "%.1f:1".format(it) },
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
)
}
// Angle slider (gesture alternative for two-finger rotate)
SliderControl(
label = "Angle",
value = params.angle,
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
)
}
}
@Composable
private fun SliderControl(
label: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
onValueChange: (Float) -> Unit
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = Color.White,
fontSize = 12.sp
)
Text(
text = formatValue(value),
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp
)
}
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = AppColors.Accent,
activeTrackColor = AppColors.Accent,
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
),
modifier = Modifier
.height(24.dp)
.semantics { contentDescription = "$label: ${formatValue(value)}" }
)
}
}
/**
* Capture button with processing indicator.
*/
@Composable
private fun CaptureButton(
isCapturing: Boolean,
isProcessing: 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,
role = Role.Button,
onClick = onClick
)
.semantics {
contentDescription = "Capture photo with tilt-shift effect"
if (isCapturing) stateDescription = "Processing photo"
},
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(innerSize)
.clip(CircleShape)
.background(if (isCapturing) AppColors.Accent else Color.White),
contentAlignment = Alignment.Center
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.Black,
strokeWidth = 3.dp
)
}
}
}
}
/**
* 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. Tap to open in viewer.",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(10.dp))
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
.clickable(role = Role.Button, onClick = onTap)
)
}
}
}