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:
Ole-Morten Duesund 2026-01-28 15:26:41 +01:00
commit 07e10ac9c3
38 changed files with 3489 additions and 0 deletions

View 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)
)
}
}

View file

@ -0,0 +1,117 @@
package no.naiv.tiltshift.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraRear
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.camera.CameraLens
/**
* Lens selection UI for switching between camera lenses.
*/
@Composable
fun LensSwitcher(
availableLenses: List<CameraLens>,
currentLens: CameraLens?,
onLensSelected: (CameraLens) -> Unit,
modifier: Modifier = Modifier
) {
if (availableLenses.size <= 1) {
// Don't show switcher if only one lens available
return
}
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
availableLenses.forEach { lens ->
LensButton(
lens = lens,
isSelected = lens.id == currentLens?.id,
onClick = { onLensSelected(lens) }
)
}
}
}
@Composable
private fun LensButton(
lens: CameraLens,
isSelected: Boolean,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) {
Color(0xFFFFB300)
} else {
Color(0x80000000)
},
label = "lens_button_bg"
)
val contentColor by animateColorAsState(
targetValue = if (isSelected) Color.Black else Color.White,
label = "lens_button_content"
)
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(
text = lens.displayName,
color = contentColor,
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
/**
* Simple camera flip button (for future front camera support).
*/
@Composable
fun CameraFlipButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(Color(0x80000000))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.CameraRear,
contentDescription = "Switch Camera",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}

View file

@ -0,0 +1,257 @@
package no.naiv.tiltshift.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateRotation
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.effect.BlurParameters
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* Type of gesture being performed.
*/
private enum class GestureType {
NONE,
DRAG_POSITION, // Single finger drag to move focus position
ROTATE, // Two-finger rotation
PINCH_SIZE, // Pinch near blur edges to resize
PINCH_ZOOM // Pinch in center to zoom camera
}
/**
* Overlay that shows tilt-shift effect controls and handles gestures.
*/
@Composable
fun TiltShiftOverlay(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
onZoomChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
var initialZoom by remember { mutableFloatStateOf(1f) }
var initialAngle by remember { mutableFloatStateOf(0f) }
var initialSize by remember { mutableFloatStateOf(0.3f) }
Canvas(
modifier = modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
currentGesture = GestureType.NONE
var previousCentroid = firstDown.position
var previousPointerCount = 1
var accumulatedRotation = 0f
var accumulatedZoom = 1f
initialAngle = params.angle
initialSize = params.size
initialZoom = 1f
do {
val event = awaitPointerEvent()
val pointers = event.changes.filter { it.pressed }
if (pointers.isEmpty()) break
val centroid = if (pointers.size >= 2) {
event.calculateCentroid()
} else {
pointers.first().position
}
when {
// Two or more fingers
pointers.size >= 2 -> {
val rotation = event.calculateRotation()
val zoom = event.calculateZoom()
// Determine gesture type based on touch positions
if (currentGesture == GestureType.NONE || currentGesture == GestureType.DRAG_POSITION) {
currentGesture = determineGestureType(
centroid,
size.width.toFloat(),
size.height.toFloat(),
params
)
}
when (currentGesture) {
GestureType.ROTATE -> {
accumulatedRotation += rotation
val newAngle = initialAngle + accumulatedRotation
onParamsChange(params.copy(angle = newAngle))
}
GestureType.PINCH_SIZE -> {
accumulatedZoom *= zoom
val newSize = (initialSize * accumulatedZoom)
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
onParamsChange(params.copy(size = newSize))
}
GestureType.PINCH_ZOOM -> {
onZoomChange(zoom)
}
else -> {}
}
}
// Single finger
pointers.size == 1 -> {
if (currentGesture == GestureType.NONE) {
currentGesture = GestureType.DRAG_POSITION
}
if (currentGesture == GestureType.DRAG_POSITION) {
val deltaY = (centroid.y - previousCentroid.y) / size.height
val newPosition = (params.position + deltaY).coerceIn(0f, 1f)
onParamsChange(params.copy(position = newPosition))
}
}
}
previousCentroid = centroid
previousPointerCount = pointers.size
// Consume all pointer changes
pointers.forEach { it.consume() }
} while (event.type != PointerEventType.Release)
currentGesture = GestureType.NONE
}
}
) {
drawTiltShiftOverlay(params)
}
}
/**
* Determines the type of two-finger gesture based on touch position.
*/
private fun determineGestureType(
centroid: Offset,
width: Float,
height: Float,
params: BlurParameters
): GestureType {
// Calculate distance from focus center line
val focusCenterY = height * params.position
val focusHalfHeight = height * params.size * 0.5f
// Rotate centroid to align with focus line
val dx = centroid.x - width / 2f
val dy = centroid.y - focusCenterY
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
val distFromCenter = kotlin.math.abs(rotatedY)
return when {
// Near the edges of the blur zone -> size adjustment
distFromCenter > focusHalfHeight * 0.7f && distFromCenter < focusHalfHeight * 1.5f -> {
GestureType.PINCH_SIZE
}
// Inside the focus zone -> rotation
distFromCenter < focusHalfHeight * 0.7f -> {
GestureType.ROTATE
}
// Outside -> camera zoom
else -> {
GestureType.PINCH_ZOOM
}
}
}
/**
* Draws the tilt-shift visualization overlay.
*/
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
val width = size.width
val height = size.height
val centerY = height * params.position
val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat())
// Colors for overlay
val focusLineColor = Color(0xFFFFB300) // Amber
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
rotate(angleDegrees, pivot = Offset(width / 2f, centerY)) {
// Draw blur zone indicators (top and bottom)
drawRect(
color = blurZoneColor,
topLeft = Offset(0f, 0f),
size = androidx.compose.ui.geometry.Size(width, centerY - focusHalfHeight)
)
drawRect(
color = blurZoneColor,
topLeft = Offset(0f, centerY + focusHalfHeight),
size = androidx.compose.ui.geometry.Size(width, height - (centerY + focusHalfHeight))
)
// Draw focus zone boundary lines
drawLine(
color = focusLineColor,
start = Offset(0f, centerY - focusHalfHeight),
end = Offset(width, centerY - focusHalfHeight),
strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
drawLine(
color = focusLineColor,
start = Offset(0f, centerY + focusHalfHeight),
end = Offset(width, centerY + focusHalfHeight),
strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
// Draw center focus line
drawLine(
color = focusLineColor,
start = Offset(0f, centerY),
end = Offset(width, centerY),
strokeWidth = 3.dp.toPx()
)
// Draw rotation indicator at center
val indicatorRadius = 30.dp.toPx()
drawCircle(
color = focusLineColor.copy(alpha = 0.5f),
radius = indicatorRadius,
center = Offset(width / 2f, centerY),
style = Stroke(width = 2.dp.toPx())
)
// Draw angle tick mark
val tickLength = 15.dp.toPx()
drawLine(
color = focusLineColor,
start = Offset(width / 2f, centerY - indicatorRadius + tickLength),
end = Offset(width / 2f, centerY - indicatorRadius - 5.dp.toPx()),
strokeWidth = 3.dp.toPx()
)
}
}

View file

@ -0,0 +1,119 @@
package no.naiv.tiltshift.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs
/**
* Zoom level presets control.
*/
@Composable
fun ZoomControl(
currentZoom: Float,
minZoom: Float,
maxZoom: Float,
onZoomSelected: (Float) -> Unit,
modifier: Modifier = Modifier
) {
// Define zoom presets based on device capabilities
val presets = listOf(
ZoomPreset(0.5f, "0.5"),
ZoomPreset(1.0f, "1"),
ZoomPreset(2.0f, "2"),
ZoomPreset(5.0f, "5")
).filter { it.zoom in minZoom..maxZoom }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
presets.forEach { preset ->
ZoomButton(
preset = preset,
isSelected = abs(currentZoom - preset.zoom) < 0.1f,
onClick = { onZoomSelected(preset.zoom) }
)
}
}
}
private data class ZoomPreset(val zoom: Float, val label: String)
@Composable
private fun ZoomButton(
preset: ZoomPreset,
isSelected: Boolean,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) {
Color(0xFFFFB300) // Amber when selected
} else {
Color(0x80000000) // Semi-transparent black
},
label = "zoom_button_bg"
)
val textColor by animateColorAsState(
targetValue = if (isSelected) Color.Black else Color.White,
label = "zoom_button_text"
)
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(
text = "${preset.label}x",
color = textColor,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
/**
* Displays current zoom level as a badge.
*/
@Composable
fun ZoomIndicator(
currentZoom: Float,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(CircleShape)
.background(Color(0x80000000))
.padding(horizontal = 12.dp, vertical = 6.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "%.1fx".format(currentZoom),
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
}