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

@ -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()
)
}
}