Initial implementation of Tilt-Shift Camera Android app
A dedicated camera app for tilt-shift photography with: - Real-time OpenGL ES 2.0 shader-based blur preview - Touch gesture controls (drag, rotate, pinch) for adjusting effect - CameraX integration for camera preview and high-res capture - EXIF metadata with GPS location support - MediaStore integration for saving to gallery - Jetpack Compose UI with haptic feedback Tech stack: Kotlin, CameraX, OpenGL ES 2.0, Jetpack Compose Min SDK: 26 (Android 8.0), Target SDK: 35 (Android 15) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
07e10ac9c3
38 changed files with 3489 additions and 0 deletions
345
app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
Normal file
345
app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
package no.naiv.tiltshift.ui
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.location.Location
|
||||
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.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
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.Settings
|
||||
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.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.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
|
||||
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) }
|
||||
|
||||
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
|
||||
var currentLocation by remember { mutableStateOf<Location?>(null) }
|
||||
|
||||
val zoomRatio by cameraManager.zoomRatio.collectAsState()
|
||||
val minZoom by cameraManager.minZoomRatio.collectAsState()
|
||||
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Start camera when surface texture is available
|
||||
LaunchedEffect(surfaceTexture) {
|
||||
surfaceTexture?.let {
|
||||
cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
cameraManager.release()
|
||||
renderer?.release()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
// Top bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Zoom indicator
|
||||
ZoomIndicator(currentZoom = zoomRatio)
|
||||
|
||||
// Settings button (placeholder)
|
||||
IconButton(
|
||||
onClick = { /* TODO: Settings */ }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom controls
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Zoom presets
|
||||
ZoomControl(
|
||||
currentZoom = zoomRatio,
|
||||
minZoom = minZoom,
|
||||
maxZoom = maxZoom,
|
||||
onZoomSelected = { zoom ->
|
||||
cameraManager.setZoom(zoom)
|
||||
haptics.click()
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.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 = false
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
isCapturing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
|
||||
.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue