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)
|
||||
)
|
||||
}
|
||||
}
|
||||
117
app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt
Normal file
117
app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
257
app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Normal file
257
app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
119
app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt
Normal file
119
app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue