Add radial mode, UI controls, front camera, update to API 35

- Add radial/elliptical blur mode with aspect ratio control
- Add UI sliders for blur intensity, falloff, and shape
- Add front camera support with flip button
- Update minimum SDK to API 35 (Android 15)
- Enable landscape orientation (fullSensor)
- Rename app to "Naiv Tilt Shift Camera"
- Set APK output name to naiv-tilt-shift
- Add project specification document

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-01-29 11:13:31 +01:00
commit d3ca23b71c
11 changed files with 679 additions and 94 deletions

View file

@ -41,8 +41,12 @@ class CameraManager(private val context: Context) {
private val _maxZoomRatio = MutableStateFlow(1.0f)
val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow()
private val _isFrontCamera = MutableStateFlow(false)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
private var surfaceSize: Size = Size(1920, 1080)
private var lifecycleOwnerRef: LifecycleOwner? = null
/**
* Starts the camera with the given lifecycle owner.
@ -53,6 +57,7 @@ class CameraManager(private val context: Context) {
surfaceTextureProvider: () -> SurfaceTexture?
) {
this.surfaceTextureProvider = surfaceTextureProvider
this.lifecycleOwnerRef = lifecycleOwner
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
@ -82,9 +87,13 @@ class CameraManager(private val context: Context) {
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()
// Get camera selector from lens controller
val cameraSelector = lensController.getCurrentLens()?.selector
?: CameraSelector.DEFAULT_BACK_CAMERA
// Select camera based on front/back preference
val cameraSelector = if (_isFrontCamera.value) {
CameraSelector.DEFAULT_FRONT_CAMERA
} else {
// Use lens controller for back camera lens selection
lensController.getCurrentLens()?.selector ?: CameraSelector.DEFAULT_BACK_CAMERA
}
try {
// Bind use cases to camera
@ -146,10 +155,19 @@ class CameraManager(private val context: Context) {
}
/**
* Switches to a different lens.
* Switches between front and back camera.
*/
fun switchCamera() {
_isFrontCamera.value = !_isFrontCamera.value
_zoomRatio.value = 1.0f // Reset zoom when switching
lifecycleOwnerRef?.let { bindCameraUseCases(it) }
}
/**
* Switches to a different lens (back camera only).
*/
fun switchLens(lensId: String, lifecycleOwner: LifecycleOwner) {
if (lensController.selectLens(lensId)) {
if (!_isFrontCamera.value && lensController.selectLens(lensId)) {
bindCameraUseCases(lifecycleOwner)
}
}
@ -169,5 +187,6 @@ class CameraManager(private val context: Context) {
camera = null
preview = null
imageCapture = null
lifecycleOwnerRef = null
}
}

View file

@ -6,25 +6,21 @@ import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RenderEffect
import android.graphics.Shader
import android.location.Location
import android.os.Build
import android.view.Surface
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.suspendCancellableCoroutine
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver
import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.OrientationDetector
import java.io.File
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Handles capturing photos with the tilt-shift effect applied.
@ -135,7 +131,7 @@ class ImageCaptureHandler(
/**
* Applies tilt-shift blur effect to a bitmap.
* This is a software fallback - on newer devices we could use RenderScript/RenderEffect.
* Supports both linear and radial modes.
*/
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
val width = source.width
@ -143,7 +139,6 @@ class ImageCaptureHandler(
// Create output bitmap
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
// For performance, we use a scaled-down version for blur and composite
val scaleFactor = 4 // Blur a 1/4 size image for speed
@ -165,7 +160,6 @@ class ImageCaptureHandler(
val mask = createGradientMask(width, height, params)
// Composite: blend original with blurred based on mask
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val pixels = IntArray(width * height)
val blurredPixels = IntArray(width * height)
val maskPixels = IntArray(width * height)
@ -199,6 +193,7 @@ class ImageCaptureHandler(
/**
* Creates a gradient mask for the tilt-shift effect.
* Supports both linear and radial modes.
*/
private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
@ -206,25 +201,47 @@ class ImageCaptureHandler(
val centerX = width * params.positionX
val centerY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f
val transitionHeight = focusHalfHeight * 0.5f
val focusSize = height * params.size * 0.5f
val transitionSize = focusSize * params.falloff
val cosAngle = cos(params.angle)
val sinAngle = sin(params.angle)
val screenAspect = width.toFloat() / height.toFloat()
for (y in 0 until height) {
for (x in 0 until width) {
// Rotate point around focus center
val dx = x - centerX
val dy = y - centerY
val rotatedY = -dx * sinAngle + dy * cosAngle
val dist = when (params.mode) {
BlurMode.LINEAR -> {
// Rotate point around focus center
val dx = x - centerX
val dy = y - centerY
val rotatedY = -dx * sinAngle + dy * cosAngle
kotlin.math.abs(rotatedY)
}
BlurMode.RADIAL -> {
// Calculate elliptical distance from center
var dx = x - centerX
var dy = y - centerY
// Calculate blur amount based on distance from focus line
val dist = kotlin.math.abs(rotatedY)
// Adjust for screen aspect ratio
dx *= screenAspect
// Rotate
val rotatedX = dx * cosAngle - dy * sinAngle
val rotatedY = dx * sinAngle + dy * cosAngle
// Apply ellipse aspect ratio
val adjustedX = rotatedX / params.aspectRatio
sqrt(adjustedX * adjustedX + rotatedY * rotatedY)
}
}
// Calculate blur amount based on distance from focus region
val blurAmount = when {
dist < focusHalfHeight -> 0f
dist < focusHalfHeight + transitionHeight -> {
(dist - focusHalfHeight) / transitionHeight
dist < focusSize -> 0f
dist < focusSize + transitionSize -> {
(dist - focusSize) / transitionSize
}
else -> 1f
}

View file

@ -1,20 +1,34 @@
package no.naiv.tiltshift.effect
/**
* Blur mode for tilt-shift effect.
*/
enum class BlurMode {
LINEAR, // Horizontal band of focus with blur above and below
RADIAL // Central focus point with blur radiating outward
}
/**
* Parameters controlling the tilt-shift blur effect.
*
* @param mode The blur mode (linear or radial)
* @param angle The rotation angle of the blur gradient in radians (0 = horizontal blur bands)
* @param positionX The horizontal center of the in-focus region (0.0 to 1.0)
* @param positionY The vertical center of the in-focus region (0.0 to 1.0)
* @param size The size of the in-focus region (0.0 to 1.0, as fraction of screen height)
* @param blurAmount The intensity of the blur effect (0.0 to 1.0)
* @param falloff Transition sharpness from focused to blurred (0.0 = sharp edge, 1.0 = gradual)
* @param aspectRatio Ellipse aspect ratio for radial mode (1.0 = circle, <1 = tall, >1 = wide)
*/
data class BlurParameters(
val mode: BlurMode = BlurMode.LINEAR,
val angle: Float = 0f,
val positionX: Float = 0.5f,
val positionY: Float = 0.5f,
val size: Float = 0.3f,
val blurAmount: Float = 0.8f
val blurAmount: Float = 0.8f,
val falloff: Float = 0.5f,
val aspectRatio: Float = 1.0f
) {
companion object {
val DEFAULT = BlurParameters()
@ -24,6 +38,10 @@ data class BlurParameters(
const val MAX_SIZE = 0.8f
const val MIN_BLUR = 0.0f
const val MAX_BLUR = 1.0f
const val MIN_FALLOFF = 0.1f
const val MAX_FALLOFF = 1.0f
const val MIN_ASPECT = 0.3f
const val MAX_ASPECT = 3.0f
}
/**
@ -56,4 +74,18 @@ data class BlurParameters(
fun withBlurAmount(amount: Float): BlurParameters {
return copy(blurAmount = amount.coerceIn(MIN_BLUR, MAX_BLUR))
}
/**
* Returns a copy with the falloff clamped to valid range.
*/
fun withFalloff(newFalloff: Float): BlurParameters {
return copy(falloff = newFalloff.coerceIn(MIN_FALLOFF, MAX_FALLOFF))
}
/**
* Returns a copy with the aspect ratio clamped to valid range.
*/
fun withAspectRatio(ratio: Float): BlurParameters {
return copy(aspectRatio = ratio.coerceIn(MIN_ASPECT, MAX_ASPECT))
}
}

View file

@ -23,11 +23,14 @@ class TiltShiftShader(private val context: Context) {
// Uniform locations
private var uTextureLocation: Int = 0
private var uModeLocation: Int = 0
private var uAngleLocation: Int = 0
private var uPositionXLocation: Int = 0
private var uPositionYLocation: Int = 0
private var uSizeLocation: Int = 0
private var uBlurAmountLocation: Int = 0
private var uFalloffLocation: Int = 0
private var uAspectRatioLocation: Int = 0
private var uResolutionLocation: Int = 0
/**
@ -61,11 +64,14 @@ class TiltShiftShader(private val context: Context) {
// Get uniform locations
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
uModeLocation = GLES20.glGetUniformLocation(programId, "uMode")
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
uPositionXLocation = GLES20.glGetUniformLocation(programId, "uPositionX")
uPositionYLocation = GLES20.glGetUniformLocation(programId, "uPositionY")
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff")
uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
// Clean up shaders (they're linked into program now)
@ -85,11 +91,14 @@ class TiltShiftShader(private val context: Context) {
GLES20.glUniform1i(uTextureLocation, 0)
// Set effect parameters
GLES20.glUniform1i(uModeLocation, if (params.mode == BlurMode.RADIAL) 1 else 0)
GLES20.glUniform1f(uAngleLocation, params.angle)
GLES20.glUniform1f(uPositionXLocation, params.positionX)
GLES20.glUniform1f(uPositionYLocation, params.positionY)
GLES20.glUniform1f(uSizeLocation, params.size)
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
GLES20.glUniform1f(uFalloffLocation, params.falloff)
GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
}

View file

@ -22,20 +22,23 @@ 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.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.material.icons.filled.FlipCameraAndroid
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.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -54,6 +57,7 @@ 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.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer
import no.naiv.tiltshift.storage.PhotoSaver
@ -90,6 +94,7 @@ fun CameraScreen(
var isCapturing by remember { mutableStateOf(false) }
var showSaveSuccess by remember { mutableStateOf(false) }
var showSaveError by remember { mutableStateOf<String?>(null) }
var showControls by remember { mutableStateOf(false) }
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
var currentLocation by remember { mutableStateOf<Location?>(null) }
@ -97,6 +102,7 @@ fun CameraScreen(
val zoomRatio by cameraManager.zoomRatio.collectAsState()
val minZoom by cameraManager.minZoomRatio.collectAsState()
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
// Collect orientation updates
LaunchedEffect(Unit) {
@ -172,30 +178,86 @@ fun CameraScreen(
modifier = Modifier.fillMaxSize()
)
// Top bar
Row(
// Top bar with controls
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
.padding(16.dp)
) {
// Zoom indicator
ZoomIndicator(currentZoom = zoomRatio)
// Settings button (placeholder)
IconButton(
onClick = { /* TODO: Settings */ }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
tint = Color.White
// Zoom indicator
ZoomIndicator(currentZoom = zoomRatio)
Row(verticalAlignment = Alignment.CenterVertically) {
// Camera flip button
IconButton(
onClick = {
cameraManager.switchCamera()
haptics.click()
}
) {
Icon(
imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch Camera",
tint = Color.White
)
}
// Toggle controls button
IconButton(
onClick = {
showControls = !showControls
haptics.tick()
}
) {
Text(
text = if (showControls) "Hide" else "Ctrl",
color = Color.White,
fontSize = 12.sp
)
}
}
}
// Mode toggle
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.Center
) {
ModeToggle(
currentMode = blurParams.mode,
onModeChange = { mode ->
blurParams = blurParams.copy(mode = mode)
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 ->
blurParams = newParams
}
)
}
// Bottom controls
Column(
modifier = Modifier
@ -204,18 +266,20 @@ fun CameraScreen(
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Zoom presets
ZoomControl(
currentZoom = zoomRatio,
minZoom = minZoom,
maxZoom = maxZoom,
onZoomSelected = { zoom ->
cameraManager.setZoom(zoom)
haptics.click()
}
)
// Zoom presets (only show for back camera)
if (!isFrontCamera) {
ZoomControl(
currentZoom = zoomRatio,
minZoom = minZoom,
maxZoom = maxZoom,
onZoomSelected = { zoom ->
cameraManager.setZoom(zoom)
haptics.click()
}
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(24.dp))
}
// Capture button
CaptureButton(
@ -234,7 +298,7 @@ fun CameraScreen(
blurParams = blurParams,
deviceRotation = currentRotation,
location = currentLocation,
isFrontCamera = false
isFrontCamera = isFrontCamera
)
when (result) {
@ -294,7 +358,7 @@ fun CameraScreen(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFFF44336))
.padding(24.dp)
) {
@ -315,6 +379,140 @@ fun CameraScreen(
}
}
/**
* 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(Color(0x80000000))
.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) Color(0xFFFFB300) else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = if (isSelected) Color.Black else Color.White,
fontSize = 14.sp
)
}
}
/**
* Control panel with sliders for blur parameters.
*/
@Composable
private fun ControlPanel(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.width(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color(0xCC000000))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Blur intensity slider
SliderControl(
label = "Blur",
value = params.blurAmount,
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
onValueChange = { onParamsChange(params.copy(blurAmount = it)) }
)
// Falloff slider
SliderControl(
label = "Falloff",
value = params.falloff,
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
onValueChange = { onParamsChange(params.copy(falloff = 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,
onValueChange = { onParamsChange(params.copy(aspectRatio = it)) }
)
}
}
}
@Composable
private fun SliderControl(
label: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
onValueChange: (Float) -> Unit
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = Color.White,
fontSize = 12.sp
)
Text(
text = "${(value * 100).toInt()}%",
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp
)
}
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = Color(0xFFFFB300),
activeTrackColor = Color(0xFFFFB300),
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
),
modifier = Modifier.height(24.dp)
)
}
}
/**
* Capture button with animation for capturing state.
*/

View file

@ -8,13 +8,13 @@ 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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope
@ -23,11 +23,13 @@ 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.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Type of gesture being performed.
@ -182,8 +184,8 @@ fun TiltShiftOverlay(
* Determines the type of two-finger gesture based on touch position.
*
* Zones (from center outward):
* - Very center (< 30% of focus height): Rotation
* - Near focus line (30% - 200% of focus height): Size adjustment
* - Very center (< 30% of focus size): Rotation
* - Near focus region (30% - 200% of focus size): Size adjustment
* - Far outside (> 200%): Camera zoom
*/
private fun determineGestureType(
@ -192,39 +194,50 @@ private fun determineGestureType(
height: Float,
params: BlurParameters
): GestureType {
// Calculate distance from focus center
val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f
val focusSize = height * params.size * 0.5f
// Rotate centroid to align with focus line
val dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
val distFromCenter = kotlin.math.abs(rotatedY)
val distFromCenter = when (params.mode) {
BlurMode.LINEAR -> {
// For linear mode, use perpendicular distance to focus line
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
kotlin.math.abs(rotatedY)
}
BlurMode.RADIAL -> {
// For radial mode, use distance from center
sqrt(dx * dx + dy * dy)
}
}
return when {
// Very center of focus zone -> rotation (small area)
distFromCenter < focusHalfHeight * 0.3f -> {
GestureType.ROTATE
}
// Anywhere near the blur effect -> size adjustment (large area)
distFromCenter < focusHalfHeight * 2.0f -> {
GestureType.PINCH_SIZE
}
distFromCenter < focusSize * 0.3f -> GestureType.ROTATE
// Near the blur effect -> size adjustment (large area)
distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE
// Far outside -> camera zoom
else -> {
GestureType.PINCH_ZOOM
}
else -> GestureType.PINCH_ZOOM
}
}
/**
* Draws the tilt-shift visualization overlay.
* Uses extended geometry so rotated elements don't clip at screen edges.
* Supports both linear and radial modes.
*/
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
when (params.mode) {
BlurMode.LINEAR -> drawLinearOverlay(params)
BlurMode.RADIAL -> drawRadialOverlay(params)
}
}
/**
* Draws the linear mode overlay (horizontal band with rotation).
*/
private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val width = size.width
val height = size.height
@ -239,23 +252,23 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
// Calculate diagonal for extended drawing (ensures coverage when rotated)
val diagonal = kotlin.math.sqrt(width * width + height * height)
val extendedHalf = diagonal // Extend lines/rects well beyond screen
val diagonal = sqrt(width * width + height * height)
val extendedHalf = diagonal
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw blur zone indicators (top and bottom) - extended horizontally
// Draw blur zone indicators (top and bottom)
drawRect(
color = blurZoneColor,
topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf),
size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf)
size = Size(extendedHalf * 2, extendedHalf)
)
drawRect(
color = blurZoneColor,
topLeft = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf)
size = Size(extendedHalf * 2, extendedHalf)
)
// Draw focus zone boundary lines - extended horizontally
// Draw focus zone boundary lines
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
@ -271,7 +284,7 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
pathEffect = dashEffect
)
// Draw center focus line - extended horizontally
// Draw center focus line
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY),
@ -298,3 +311,70 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
)
}
}
/**
* Draws the radial mode overlay (ellipse/circle).
*/
private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val width = size.width
val height = size.height
val centerX = width * params.positionX
val centerY = height * params.positionY
val focusRadius = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat())
// Colors for overlay
val focusLineColor = Color(0xFFFFB300) // Amber
val blurZoneColor = Color(0x30FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f)
// Calculate ellipse dimensions based on aspect ratio
val ellipseWidth = focusRadius * 2 * params.aspectRatio
val ellipseHeight = focusRadius * 2
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw focus ellipse outline (inner boundary)
drawOval(
color = focusLineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
size = Size(ellipseWidth, ellipseHeight),
style = Stroke(width = 3.dp.toPx())
)
// Draw outer blur boundary (with falloff)
val outerScale = 1f + params.falloff
drawOval(
color = focusLineColor.copy(alpha = 0.5f),
topLeft = Offset(
centerX - (ellipseWidth * outerScale) / 2,
centerY - (ellipseHeight * outerScale) / 2
),
size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale),
style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect)
)
// Draw center crosshair
val crosshairSize = 20.dp.toPx()
drawLine(
color = focusLineColor,
start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 2.dp.toPx()
)
drawLine(
color = focusLineColor,
start = Offset(centerX, centerY - crosshairSize),
end = Offset(centerX, centerY + crosshairSize),
strokeWidth = 2.dp.toPx()
)
// Draw rotation indicator (small line at top of ellipse)
drawLine(
color = focusLineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),
end = Offset(centerX, centerY - ellipseHeight / 2 - 20.dp.toPx()),
strokeWidth = 3.dp.toPx()
)
}
}