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

@ -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.
*/