Compare commits

..

No commits in common. "efe406f0b0cc8a043eb7710873e58eb3c026761f" and "5e08fb9c13c27c2579150ba1476f5eece0ed2377" have entirely different histories.

10 changed files with 263 additions and 650 deletions

View file

@ -80,7 +80,6 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
// Compose // Compose

View file

@ -1,10 +1,7 @@
package no.naiv.tiltshift package no.naiv.tiltshift
import android.Manifest import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -28,7 +25,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -42,9 +38,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import no.naiv.tiltshift.ui.CameraScreen import no.naiv.tiltshift.ui.CameraScreen
import no.naiv.tiltshift.ui.theme.AppColors
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -102,17 +96,12 @@ private fun TiltShiftApp() {
} }
} }
else -> { else -> {
// Permanently denied: not granted AND rationale not shown
val cameraPermanentlyDenied = !cameraPermission.status.isGranted &&
!cameraPermission.status.shouldShowRationale
// Show permission request UI // Show permission request UI
PermissionRequestScreen( PermissionRequestScreen(
onRequestCamera = { cameraPermission.launchPermissionRequest() }, onRequestCamera = { cameraPermission.launchPermissionRequest() },
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() }, onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
cameraGranted = cameraPermission.status.isGranted, cameraGranted = cameraPermission.status.isGranted,
locationGranted = locationPermissions.allPermissionsGranted, locationGranted = locationPermissions.allPermissionsGranted
cameraPermanentlyDenied = cameraPermanentlyDenied
) )
} }
} }
@ -124,8 +113,7 @@ private fun PermissionRequestScreen(
onRequestCamera: () -> Unit, onRequestCamera: () -> Unit,
onRequestLocation: () -> Unit, onRequestLocation: () -> Unit,
cameraGranted: Boolean, cameraGranted: Boolean,
locationGranted: Boolean, locationGranted: Boolean
cameraPermanentlyDenied: Boolean
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -158,7 +146,6 @@ private fun PermissionRequestScreen(
title = "Camera", title = "Camera",
description = "Required to take photos", description = "Required to take photos",
isGranted = cameraGranted, isGranted = cameraGranted,
isPermanentlyDenied = cameraPermanentlyDenied,
onRequest = onRequestCamera onRequest = onRequestCamera
) )
@ -181,10 +168,8 @@ private fun PermissionItem(
title: String, title: String,
description: String, description: String,
isGranted: Boolean, isGranted: Boolean,
isPermanentlyDenied: Boolean = false,
onRequest: () -> Unit onRequest: () -> Unit
) { ) {
val context = LocalContext.current
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
@ -197,7 +182,7 @@ private fun PermissionItem(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = if (isGranted) AppColors.Success else AppColors.Accent, tint = if (isGranted) Color(0xFF4CAF50) else Color(0xFFFFB300),
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
@ -216,31 +201,13 @@ private fun PermissionItem(
if (!isGranted) { if (!isGranted) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
if (isPermanentlyDenied) { Button(
Button( onClick = onRequest,
onClick = { colors = ButtonDefaults.buttonColors(
context.startActivity( containerColor = Color(0xFFFFB300)
Intent( )
Settings.ACTION_APPLICATION_DETAILS_SETTINGS, ) {
Uri.fromParts("package", context.packageName, null) Text("Grant", color = Color.Black)
)
)
},
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Accent
)
) {
Text("Open Settings", color = Color.Black)
}
} else {
Button(
onClick = onRequest,
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.Accent
)
) {
Text("Grant", color = Color.Black)
}
} }
} }
} }

View file

@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.Executor import java.util.concurrent.Executor
import java.util.concurrent.Executors
/** /**
* Manages CameraX camera setup and controls. * Manages CameraX camera setup and controls.
@ -57,9 +56,6 @@ class CameraManager(private val context: Context) {
private val _isFrontCamera = MutableStateFlow(false) private val _isFrontCamera = MutableStateFlow(false)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow() val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
/** Background executor for image capture callbacks to avoid blocking the main thread. */
private val captureExecutor: Executor = Executors.newSingleThreadExecutor()
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
private var surfaceSize: Size = Size(1920, 1080) private var surfaceSize: Size = Size(1920, 1080)
private var lifecycleOwnerRef: LifecycleOwner? = null private var lifecycleOwnerRef: LifecycleOwner? = null
@ -198,12 +194,10 @@ class CameraManager(private val context: Context) {
} }
/** /**
* Gets the background executor for image capture callbacks. * Gets the executor for image capture callbacks.
* Uses a dedicated thread to avoid blocking the main/UI thread during heavy
* bitmap processing (decode, rotate, tilt-shift effect).
*/ */
fun getExecutor(): Executor { fun getExecutor(): Executor {
return captureExecutor return ContextCompat.getMainExecutor(context)
} }
/** /**

View file

@ -3,6 +3,8 @@ package no.naiv.tiltshift.ui
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.location.Location
import android.net.Uri
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
@ -32,15 +34,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipCameraAndroid import androidx.compose.material.icons.filled.FlipCameraAndroid
import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@ -53,96 +51,124 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.Image
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.LiveRegionMode import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner import kotlinx.coroutines.delay
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collectLatest 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.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer import no.naiv.tiltshift.effect.TiltShiftRenderer
import no.naiv.tiltshift.ui.theme.AppColors 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. * Main camera screen with tilt-shift controls.
* Uses CameraViewModel to survive configuration changes.
*/ */
@Composable @Composable
fun CameraScreen( fun CameraScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier
viewModel: CameraViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
// GL state (view-layer, not in ViewModel) // 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 surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) } var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) } var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
// Collect ViewModel state var isCapturing by remember { mutableStateOf(false) }
val blurParams by viewModel.blurParams.collectAsState() var showSaveSuccess by remember { mutableStateOf(false) }
val isCapturing by viewModel.isCapturing.collectAsState() var showSaveError by remember { mutableStateOf<String?>(null) }
val isProcessing by viewModel.isProcessing.collectAsState() var showControls by remember { mutableStateOf(false) }
val showSaveSuccess by viewModel.showSaveSuccess.collectAsState()
val showSaveError by viewModel.showSaveError.collectAsState() // Thumbnail state for last captured photo
val showControls by viewModel.showControls.collectAsState() var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
val lastSavedUri by viewModel.lastSavedUri.collectAsState() var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
val galleryBitmap by viewModel.galleryBitmap.collectAsState() // Gallery preview mode: non-null means we're previewing a gallery image
var galleryBitmap by remember { mutableStateOf<Bitmap?>(null) }
var galleryImageUri by remember { mutableStateOf<Uri?>(null) }
val isGalleryPreview = galleryBitmap != null val isGalleryPreview = galleryBitmap != null
val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState() var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState() var currentLocation by remember { mutableStateOf<Location?>(null) }
val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
val cameraError by viewModel.cameraManager.error.collectAsState()
// Gallery picker // Gallery picker: load image for interactive preview before processing
val galleryLauncher = rememberLauncherForActivityResult( val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia() contract = ActivityResultContracts.PickVisualMedia()
) { uri -> ) { uri ->
if (uri != null) { if (uri != null && !isCapturing && !isGalleryPreview) {
viewModel.loadGalleryImage(uri) scope.launch {
val bitmap = captureHandler.loadGalleryImage(uri)
if (bitmap != null) {
galleryBitmap = bitmap
galleryImageUri = uri
} else {
haptics.error()
showSaveError = "Failed to load image"
delay(2000)
showSaveError = null
}
}
} }
} }
// Show camera errors val zoomRatio by cameraManager.zoomRatio.collectAsState()
val minZoom by cameraManager.minZoomRatio.collectAsState()
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
val cameraError by cameraManager.error.collectAsState()
// Show camera errors via the existing error UI
LaunchedEffect(cameraError) { LaunchedEffect(cameraError) {
cameraError?.let { message -> cameraError?.let { message ->
viewModel.showCameraError(message) showSaveError = message
viewModel.cameraManager.clearError() cameraManager.clearError()
delay(2000)
showSaveError = null
} }
} }
// Collect orientation updates // Collect orientation updates
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.orientationDetector.orientationFlow().collectLatest { rotation -> orientationDetector.orientationFlow().collectLatest { rotation ->
viewModel.updateRotation(rotation) currentRotation = rotation
} }
} }
// Collect location updates // Collect location updates
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.locationProvider.locationFlow().collectLatest { location -> locationProvider.locationFlow().collectLatest { location ->
viewModel.updateLocation(location) currentLocation = location
} }
} }
@ -161,14 +187,17 @@ fun CameraScreen(
// Start camera when surface texture is available // Start camera when surface texture is available
LaunchedEffect(surfaceTexture) { LaunchedEffect(surfaceTexture) {
surfaceTexture?.let { surfaceTexture?.let {
viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture } cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
} }
} }
// Cleanup GL resources (ViewModel handles its own cleanup in onCleared) // Cleanup
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
cameraManager.release()
renderer?.release() renderer?.release()
lastThumbnailBitmap?.recycle()
galleryBitmap?.recycle()
} }
} }
@ -182,7 +211,7 @@ fun CameraScreen(
galleryBitmap?.let { bmp -> galleryBitmap?.let { bmp ->
Image( Image(
bitmap = bmp.asImageBitmap(), bitmap = bmp.asImageBitmap(),
contentDescription = "Gallery image preview. Adjust tilt-shift parameters then tap Apply.", contentDescription = "Gallery preview",
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -215,12 +244,13 @@ fun CameraScreen(
TiltShiftOverlay( TiltShiftOverlay(
params = blurParams, params = blurParams,
onParamsChange = { newParams -> onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams) blurParams = newParams
haptics.tick()
}, },
onZoomChange = { zoomDelta -> onZoomChange = { zoomDelta ->
if (!isGalleryPreview) { if (!isGalleryPreview) {
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
viewModel.cameraManager.setZoom(newZoom) cameraManager.setZoom(newZoom)
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@ -239,6 +269,7 @@ fun CameraScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (!isGalleryPreview) { if (!isGalleryPreview) {
// Zoom indicator
ZoomIndicator(currentZoom = zoomRatio) ZoomIndicator(currentZoom = zoomRatio)
} else { } else {
Spacer(modifier = Modifier.width(1.dp)) Spacer(modifier = Modifier.width(1.dp))
@ -249,32 +280,29 @@ fun CameraScreen(
// Camera flip button // Camera flip button
IconButton( IconButton(
onClick = { onClick = {
viewModel.cameraManager.switchCamera() cameraManager.switchCamera()
viewModel.haptics.click() haptics.click()
} }
) { ) {
Icon( Icon(
imageVector = Icons.Default.FlipCameraAndroid, imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch between front and back camera", contentDescription = "Switch Camera",
tint = Color.White tint = Color.White
) )
} }
} }
// Toggle controls button (tune icon instead of cryptic "Ctrl") // Toggle controls button
IconButton( IconButton(
onClick = { onClick = {
viewModel.toggleControls() showControls = !showControls
viewModel.haptics.tick() haptics.tick()
},
modifier = Modifier.semantics {
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
} }
) { ) {
Icon( Text(
imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune, text = if (showControls) "Hide" else "Ctrl",
contentDescription = if (showControls) "Hide blur controls" else "Show blur controls", color = Color.White,
tint = Color.White fontSize = 12.sp
) )
} }
} }
@ -290,8 +318,8 @@ fun CameraScreen(
ModeToggle( ModeToggle(
currentMode = blurParams.mode, currentMode = blurParams.mode,
onModeChange = { mode -> onModeChange = { mode ->
viewModel.updateBlurParams(blurParams.copy(mode = mode)) blurParams = blurParams.copy(mode = mode)
viewModel.haptics.click() haptics.click()
} }
) )
} }
@ -309,9 +337,8 @@ fun CameraScreen(
ControlPanel( ControlPanel(
params = blurParams, params = blurParams,
onParamsChange = { newParams -> onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams) blurParams = newParams
}, }
onReset = { viewModel.resetBlurParams() }
) )
} }
@ -325,93 +352,108 @@ fun CameraScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (isGalleryPreview) { if (isGalleryPreview) {
// Gallery preview mode: Cancel | Apply (matched layout to camera mode) // Gallery preview mode: Cancel | Apply
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp) horizontalArrangement = Arrangement.spacedBy(48.dp)
) { ) {
// Cancel button (same 52dp as gallery button for layout consistency) // Cancel button
IconButton( IconButton(
onClick = { viewModel.cancelGalleryPreview() }, onClick = {
val oldBitmap = galleryBitmap
galleryBitmap = null
galleryImageUri = null
oldBitmap?.recycle()
},
modifier = Modifier modifier = Modifier
.size(52.dp) .size(56.dp)
.clip(CircleShape) .clip(CircleShape)
.background(AppColors.OverlayDark) .background(Color(0x80000000))
.semantics { contentDescription = "Cancel gallery import" }
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = null, contentDescription = "Cancel",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
} }
// Apply button (same 72dp as capture button for layout consistency) // Apply button
Box( IconButton(
modifier = Modifier onClick = {
.size(72.dp) val uri = galleryImageUri ?: return@IconButton
.clip(CircleShape) if (!isCapturing) {
.border(4.dp, Color.White, CircleShape) isCapturing = true
.clickable( haptics.heavyClick()
enabled = !isCapturing, scope.launch {
role = Role.Button, val result = captureHandler.processExistingImage(
onClick = { viewModel.applyGalleryEffect() } imageUri = uri,
) blurParams = blurParams,
.semantics { location = currentLocation
contentDescription = "Apply tilt-shift effect to gallery image" )
if (isCapturing) stateDescription = "Processing" when (result) {
}, is SaveResult.Success -> {
contentAlignment = Alignment.Center haptics.success()
) { val oldThumb = lastThumbnailBitmap
Box( lastThumbnailBitmap = result.thumbnail
modifier = Modifier lastSavedUri = result.uri
.size(if (isCapturing) 48.dp else 60.dp) oldThumb?.recycle()
.clip(CircleShape) showSaveSuccess = true
.background(if (isCapturing) AppColors.Accent.copy(alpha = 0.5f) else AppColors.Accent), delay(1500)
contentAlignment = Alignment.Center showSaveSuccess = false
) { }
if (isProcessing) { is SaveResult.Error -> {
CircularProgressIndicator( haptics.error()
modifier = Modifier.size(24.dp), showSaveError = result.message
color = Color.Black, delay(2000)
strokeWidth = 3.dp showSaveError = null
) }
} else { }
Icon( val oldGalleryBitmap = galleryBitmap
imageVector = Icons.Default.Check, galleryBitmap = null
contentDescription = null, galleryImageUri = null
tint = Color.Black, oldGalleryBitmap?.recycle()
modifier = Modifier.size(28.dp) isCapturing = false
) }
} }
} },
enabled = !isCapturing,
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(Color(0xFFFFB300))
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Apply effect",
tint = Color.Black,
modifier = Modifier.size(28.dp)
)
} }
// Spacer for visual symmetry
Spacer(modifier = Modifier.size(52.dp))
} }
} else { } else {
// Camera mode: Zoom presets + Gallery | Capture | Spacer // Camera mode: Zoom presets + Gallery | Capture | Spacer
// Zoom presets (only show for back camera)
if (!isFrontCamera) { if (!isFrontCamera) {
ZoomControl( ZoomControl(
currentZoom = zoomRatio, currentZoom = zoomRatio,
minZoom = minZoom, minZoom = minZoom,
maxZoom = maxZoom, maxZoom = maxZoom,
onZoomSelected = { zoom -> onZoomSelected = { zoom ->
viewModel.cameraManager.setZoom(zoom) cameraManager.setZoom(zoom)
viewModel.haptics.click() haptics.click()
} }
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
// Gallery button | Capture button | Spacer for symmetry
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp) horizontalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
// Gallery picker button (with background for discoverability) // Gallery picker button
IconButton( IconButton(
onClick = { onClick = {
if (!isCapturing) { if (!isCapturing) {
@ -421,14 +463,11 @@ fun CameraScreen(
} }
}, },
enabled = !isCapturing, enabled = !isCapturing,
modifier = Modifier modifier = Modifier.size(52.dp)
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
) { ) {
Icon( Icon(
imageVector = Icons.Default.PhotoLibrary, imageVector = Icons.Default.PhotoLibrary,
contentDescription = "Pick image from gallery", contentDescription = "Pick from gallery",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
@ -437,8 +476,46 @@ fun CameraScreen(
// Capture button // Capture button
CaptureButton( CaptureButton(
isCapturing = isCapturing, isCapturing = isCapturing,
isProcessing = isProcessing, onClick = {
onClick = { viewModel.capturePhoto() } 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 = isFrontCamera
)
when (result) {
is SaveResult.Success -> {
haptics.success()
val oldThumb = lastThumbnailBitmap
lastThumbnailBitmap = result.thumbnail
lastSavedUri = result.uri
oldThumb?.recycle()
showSaveSuccess = true
delay(1500)
showSaveSuccess = false
}
is SaveResult.Error -> {
haptics.error()
showSaveError = result.message
delay(2000)
showSaveError = null
}
}
}
isCapturing = false
}
}
}
) )
// Spacer for visual symmetry with gallery button // Spacer for visual symmetry with gallery button
@ -469,32 +546,30 @@ fun CameraScreen(
.padding(bottom = 48.dp, end = 16.dp) .padding(bottom = 48.dp, end = 16.dp)
) )
// Success indicator (announced to accessibility) // Success indicator
AnimatedVisibility( AnimatedVisibility(
visible = showSaveSuccess, visible = showSaveSuccess,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
modifier = Modifier modifier = Modifier.align(Alignment.Center)
.align(Alignment.Center)
.semantics { liveRegion = LiveRegionMode.Polite }
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(80.dp) .size(80.dp)
.clip(CircleShape) .clip(CircleShape)
.background(AppColors.Success), .background(Color(0xFF4CAF50)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = "Photo saved successfully", contentDescription = "Saved",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) )
} }
} }
// Error indicator (announced to accessibility) // Error indicator
AnimatedVisibility( AnimatedVisibility(
visible = showSaveError != null, visible = showSaveError != null,
enter = fadeIn(), enter = fadeIn(),
@ -502,18 +577,17 @@ fun CameraScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.Center) .align(Alignment.Center)
.padding(32.dp) .padding(32.dp)
.semantics { liveRegion = LiveRegionMode.Assertive }
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(AppColors.Error) .background(Color(0xFFF44336))
.padding(24.dp) .padding(24.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = null, contentDescription = "Error",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
@ -521,35 +595,10 @@ fun CameraScreen(
Text( Text(
text = showSaveError ?: "Error", text = showSaveError ?: "Error",
color = Color.White, color = Color.White,
fontSize = 14.sp, fontSize = 14.sp
maxLines = 3
) )
} }
} }
// Processing overlay
AnimatedVisibility(
visible = isProcessing,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
if (!showSaveSuccess) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = AppColors.Accent,
strokeWidth = 3.dp,
modifier = Modifier.size(36.dp)
)
}
}
}
} }
} }
@ -565,7 +614,7 @@ private fun ModeToggle(
Row( Row(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(AppColors.OverlayDark) .background(Color(0x80000000))
.padding(4.dp), .padding(4.dp),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@ -592,13 +641,9 @@ private fun ModeButton(
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(if (isSelected) AppColors.Accent else Color.Transparent) .background(if (isSelected) Color(0xFFFFB300) else Color.Transparent)
.clickable(role = Role.Button, onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 8.dp),
.semantics {
stateDescription = if (isSelected) "Selected" else "Not selected"
contentDescription = "$text blur mode"
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@ -611,15 +656,14 @@ private fun ModeButton(
/** /**
* Control panel with sliders for blur parameters. * Control panel with sliders for blur parameters.
* Includes position/size/angle sliders as gesture alternatives for accessibility.
*/ */
@Composable @Composable
private fun ControlPanel( private fun ControlPanel(
params: BlurParameters, params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit, onParamsChange: (BlurParameters) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// Use rememberUpdatedState to avoid stale closure capture during slider drags
val currentParams by rememberUpdatedState(params) val currentParams by rememberUpdatedState(params)
val currentOnParamsChange by rememberUpdatedState(onParamsChange) val currentOnParamsChange by rememberUpdatedState(onParamsChange)
@ -627,34 +671,15 @@ private fun ControlPanel(
modifier = modifier modifier = modifier
.width(200.dp) .width(200.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(AppColors.OverlayDarker) .background(Color(0xCC000000))
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Reset button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onReset,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = "Reset all parameters to defaults",
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
}
// Blur intensity slider // Blur intensity slider
SliderControl( SliderControl(
label = "Blur", label = "Blur",
value = params.blurAmount, value = params.blurAmount,
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR, valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) } onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
) )
@ -663,38 +688,19 @@ private fun ControlPanel(
label = "Falloff", label = "Falloff",
value = params.falloff, value = params.falloff,
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF, valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) } onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
) )
// Size slider (gesture alternative for pinch-to-resize)
SliderControl(
label = "Size",
value = params.size,
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
)
// Aspect ratio slider (radial mode only) // Aspect ratio slider (radial mode only)
if (params.mode == BlurMode.RADIAL) { if (params.mode == BlurMode.RADIAL) {
SliderControl( SliderControl(
label = "Shape", label = "Shape",
value = params.aspectRatio, value = params.aspectRatio,
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT, valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
formatValue = { "%.1f:1".format(it) },
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
) )
} }
// Angle slider (gesture alternative for two-finger rotate)
SliderControl(
label = "Angle",
value = params.angle,
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
)
} }
} }
@ -703,7 +709,6 @@ private fun SliderControl(
label: String, label: String,
value: Float, value: Float,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
onValueChange: (Float) -> Unit onValueChange: (Float) -> Unit
) { ) {
Column { Column {
@ -717,7 +722,7 @@ private fun SliderControl(
fontSize = 12.sp fontSize = 12.sp
) )
Text( Text(
text = formatValue(value), text = "${(value * 100).toInt()}%",
color = Color.White.copy(alpha = 0.7f), color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp fontSize = 12.sp
) )
@ -727,24 +732,21 @@ private fun SliderControl(
onValueChange = onValueChange, onValueChange = onValueChange,
valueRange = valueRange, valueRange = valueRange,
colors = SliderDefaults.colors( colors = SliderDefaults.colors(
thumbColor = AppColors.Accent, thumbColor = Color(0xFFFFB300),
activeTrackColor = AppColors.Accent, activeTrackColor = Color(0xFFFFB300),
inactiveTrackColor = Color.White.copy(alpha = 0.3f) inactiveTrackColor = Color.White.copy(alpha = 0.3f)
), ),
modifier = Modifier modifier = Modifier.height(24.dp)
.height(24.dp)
.semantics { contentDescription = "$label: ${formatValue(value)}" }
) )
} }
} }
/** /**
* Capture button with processing indicator. * Capture button with animation for capturing state.
*/ */
@Composable @Composable
private fun CaptureButton( private fun CaptureButton(
isCapturing: Boolean, isCapturing: Boolean,
isProcessing: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -756,32 +758,15 @@ private fun CaptureButton(
.size(outerSize) .size(outerSize)
.clip(CircleShape) .clip(CircleShape)
.border(4.dp, Color.White, CircleShape) .border(4.dp, Color.White, CircleShape)
.clickable( .clickable(enabled = !isCapturing, onClick = onClick),
enabled = !isCapturing,
role = Role.Button,
onClick = onClick
)
.semantics {
contentDescription = "Capture photo with tilt-shift effect"
if (isCapturing) stateDescription = "Processing photo"
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(innerSize) .size(innerSize)
.clip(CircleShape) .clip(CircleShape)
.background(if (isCapturing) AppColors.Accent else Color.White), .background(if (isCapturing) Color(0xFFFFB300) else Color.White)
contentAlignment = Alignment.Center )
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.Black,
strokeWidth = 3.dp
)
}
}
} }
} }
@ -804,13 +789,13 @@ private fun LastPhotoThumbnail(
thumbnail?.let { bmp -> thumbnail?.let { bmp ->
Image( Image(
bitmap = bmp.asImageBitmap(), bitmap = bmp.asImageBitmap(),
contentDescription = "Last captured photo. Tap to open in viewer.", contentDescription = "Last captured photo",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(52.dp) .size(52.dp)
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.border(2.dp, Color.White, RoundedCornerShape(10.dp)) .border(2.dp, Color.White, RoundedCornerShape(10.dp))
.clickable(role = Role.Button, onClick = onTap) .clickable(onClick = onTap)
) )
} }
} }

View file

@ -1,209 +0,0 @@
package no.naiv.tiltshift.ui
import android.app.Application
import android.graphics.Bitmap
import android.location.Location
import android.net.Uri
import android.util.Log
import android.view.Surface
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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.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
/**
* ViewModel for the camera screen.
* Survives configuration changes (rotation) and process death (via SavedStateHandle for primitives).
*/
class CameraViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "CameraViewModel"
}
val cameraManager = CameraManager(application)
val photoSaver = PhotoSaver(application)
val captureHandler = ImageCaptureHandler(application, photoSaver)
val haptics = HapticFeedback(application)
val orientationDetector = OrientationDetector(application)
val locationProvider = LocationProvider(application)
// Blur parameters — preserved across config changes
private val _blurParams = MutableStateFlow(BlurParameters.DEFAULT)
val blurParams: StateFlow<BlurParameters> = _blurParams.asStateFlow()
// Capture state
private val _isCapturing = MutableStateFlow(false)
val isCapturing: StateFlow<Boolean> = _isCapturing.asStateFlow()
private val _showSaveSuccess = MutableStateFlow(false)
val showSaveSuccess: StateFlow<Boolean> = _showSaveSuccess.asStateFlow()
private val _showSaveError = MutableStateFlow<String?>(null)
val showSaveError: StateFlow<String?> = _showSaveError.asStateFlow()
private val _showControls = MutableStateFlow(false)
val showControls: StateFlow<Boolean> = _showControls.asStateFlow()
// Thumbnail state
private val _lastSavedUri = MutableStateFlow<Uri?>(null)
val lastSavedUri: StateFlow<Uri?> = _lastSavedUri.asStateFlow()
private val _lastThumbnailBitmap = MutableStateFlow<Bitmap?>(null)
val lastThumbnailBitmap: StateFlow<Bitmap?> = _lastThumbnailBitmap.asStateFlow()
// Gallery preview state
private val _galleryBitmap = MutableStateFlow<Bitmap?>(null)
val galleryBitmap: StateFlow<Bitmap?> = _galleryBitmap.asStateFlow()
private val _galleryImageUri = MutableStateFlow<Uri?>(null)
val galleryImageUri: StateFlow<Uri?> = _galleryImageUri.asStateFlow()
val isGalleryPreview: Boolean get() = _galleryBitmap.value != null
// Device state
private val _currentRotation = MutableStateFlow(Surface.ROTATION_0)
val currentRotation: StateFlow<Int> = _currentRotation.asStateFlow()
private val _currentLocation = MutableStateFlow<Location?>(null)
val currentLocation: StateFlow<Location?> = _currentLocation.asStateFlow()
// Processing indicator
private val _isProcessing = MutableStateFlow(false)
val isProcessing: StateFlow<Boolean> = _isProcessing.asStateFlow()
fun updateBlurParams(params: BlurParameters) {
_blurParams.value = params
}
fun resetBlurParams() {
_blurParams.value = BlurParameters.DEFAULT
}
fun toggleControls() {
_showControls.value = !_showControls.value
}
fun updateRotation(rotation: Int) {
_currentRotation.value = rotation
}
fun updateLocation(location: Location?) {
_currentLocation.value = location
}
fun loadGalleryImage(uri: Uri) {
if (_isCapturing.value || isGalleryPreview) return
viewModelScope.launch {
_isProcessing.value = true
val bitmap = captureHandler.loadGalleryImage(uri)
_isProcessing.value = false
if (bitmap != null) {
_galleryBitmap.value = bitmap
_galleryImageUri.value = uri
} else {
haptics.error()
showError("Failed to load image")
}
}
}
fun cancelGalleryPreview() {
val old = _galleryBitmap.value
_galleryBitmap.value = null
_galleryImageUri.value = null
old?.recycle()
}
fun applyGalleryEffect() {
val uri = _galleryImageUri.value ?: return
if (_isCapturing.value) return
_isCapturing.value = true
_isProcessing.value = true
haptics.heavyClick()
viewModelScope.launch {
val result = captureHandler.processExistingImage(
imageUri = uri,
blurParams = _blurParams.value,
location = _currentLocation.value
)
handleSaveResult(result)
cancelGalleryPreview()
_isCapturing.value = false
_isProcessing.value = false
}
}
fun capturePhoto() {
if (_isCapturing.value) return
val imageCapture = cameraManager.imageCapture ?: return
_isCapturing.value = true
_isProcessing.value = true
haptics.heavyClick()
viewModelScope.launch {
val result = captureHandler.capturePhoto(
imageCapture = imageCapture,
executor = cameraManager.getExecutor(),
blurParams = _blurParams.value,
deviceRotation = _currentRotation.value,
location = _currentLocation.value,
isFrontCamera = cameraManager.isFrontCamera.value
)
handleSaveResult(result)
_isCapturing.value = false
_isProcessing.value = false
}
}
private fun handleSaveResult(result: SaveResult) {
when (result) {
is SaveResult.Success -> {
haptics.success()
val oldThumb = _lastThumbnailBitmap.value
_lastThumbnailBitmap.value = result.thumbnail
_lastSavedUri.value = result.uri
oldThumb?.recycle()
viewModelScope.launch {
_showSaveSuccess.value = true
kotlinx.coroutines.delay(1500)
_showSaveSuccess.value = false
}
}
is SaveResult.Error -> {
haptics.error()
showError(result.message)
}
}
}
private fun showError(message: String) {
viewModelScope.launch {
_showSaveError.value = message
kotlinx.coroutines.delay(2000)
_showSaveError.value = null
}
}
fun showCameraError(message: String) {
showError(message)
}
override fun onCleared() {
super.onCleared()
cameraManager.release()
_lastThumbnailBitmap.value?.recycle()
_galleryBitmap.value?.recycle()
}
}

View file

@ -16,16 +16,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.camera.CameraLens import no.naiv.tiltshift.camera.CameraLens
import no.naiv.tiltshift.ui.theme.AppColors
/** /**
* Lens selection UI for switching between camera lenses. * Lens selection UI for switching between camera lenses.
@ -65,9 +59,9 @@ private fun LensButton(
) { ) {
val backgroundColor by animateColorAsState( val backgroundColor by animateColorAsState(
targetValue = if (isSelected) { targetValue = if (isSelected) {
AppColors.Accent Color(0xFFFFB300)
} else { } else {
AppColors.OverlayDark Color(0x80000000)
}, },
label = "lens_button_bg" label = "lens_button_bg"
) )
@ -82,8 +76,7 @@ private fun LensButton(
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(backgroundColor) .background(backgroundColor)
.clickable(role = Role.Button, onClick = onClick) .clickable(onClick = onClick),
.semantics { selected = isSelected; contentDescription = "${lens.displayName} lens" },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(

View file

@ -22,12 +22,9 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.ui.theme.AppColors
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
@ -42,16 +39,13 @@ private enum class GestureType {
DRAG_POSITION, // Single finger drag to move focus center DRAG_POSITION, // Single finger drag to move focus center
ROTATE, // Two-finger rotation ROTATE, // Two-finger rotation
PINCH_SIZE, // Pinch near blur edges to resize PINCH_SIZE, // Pinch near blur edges to resize
PINCH_ZOOM // Pinch far outside to zoom camera PINCH_ZOOM // Pinch in center to zoom camera
} }
// Sensitivity factor for size pinch (lower = less sensitive) // Sensitivity factor for size pinch (lower = less sensitive)
private const val SIZE_SENSITIVITY = 0.3f private const val SIZE_SENSITIVITY = 0.3f
private const val ZOOM_SENSITIVITY = 0.5f private const val ZOOM_SENSITIVITY = 0.5f
/** Minimum focus size (in px) for gesture zones to ensure usable touch targets. */
private const val MIN_FOCUS_SIZE_PX = 150f
/** /**
* Calculates the angle between two touch points. * Calculates the angle between two touch points.
*/ */
@ -76,16 +70,9 @@ fun TiltShiftOverlay(
var currentGesture by remember { mutableStateOf(GestureType.NONE) } var currentGesture by remember { mutableStateOf(GestureType.NONE) }
val modeLabel = if (params.mode == BlurMode.LINEAR) "linear" else "radial"
Canvas( Canvas(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.semantics {
contentDescription = "Tilt-shift overlay: $modeLabel mode. " +
"Drag to move focus. Pinch near edges to resize. " +
"Pinch near center to rotate. Use sliders in controls panel for alternative adjustment."
}
.pointerInput(Unit) { .pointerInput(Unit) {
awaitEachGesture { awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false) val firstDown = awaitFirstDown(requireUnconsumed = false)
@ -200,8 +187,6 @@ fun TiltShiftOverlay(
* - Very center (< 30% of focus size): Rotation * - Very center (< 30% of focus size): Rotation
* - Near focus region (30% - 200% of focus size): Size adjustment * - Near focus region (30% - 200% of focus size): Size adjustment
* - Far outside (> 200%): Camera zoom * - Far outside (> 200%): Camera zoom
*
* Focus size is clamped to a minimum pixel value to keep zones usable at small sizes.
*/ */
private fun determineGestureType( private fun determineGestureType(
centroid: Offset, centroid: Offset,
@ -211,8 +196,7 @@ private fun determineGestureType(
): GestureType { ): GestureType {
val focusCenterX = width * params.positionX val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY val focusCenterY = height * params.positionY
// Clamp focus size to minimum to keep rotation zone reachable val focusSize = height * params.size * 0.5f
val focusSize = maxOf(height * params.size * 0.5f, MIN_FOCUS_SIZE_PX)
val dx = centroid.x - focusCenterX val dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY val dy = centroid.y - focusCenterY
@ -252,7 +236,6 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
/** /**
* Draws the linear mode overlay (horizontal band with rotation). * Draws the linear mode overlay (horizontal band with rotation).
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
*/ */
private fun DrawScope.drawLinearOverlay(params: BlurParameters) { private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val width = size.width val width = size.width
@ -263,20 +246,15 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val focusHalfHeight = height * params.size * 0.5f val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat()) val angleDegrees = params.angle * (180f / PI.toFloat())
val focusLineColor = AppColors.Accent // Colors for overlay
val outlineColor = AppColors.OverlayOutline val focusLineColor = Color(0xFFFFB300) // Amber
val blurZoneColor = AppColors.OverlayLinearBlur val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f) val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
// Calculate diagonal for extended drawing (ensures coverage when rotated) // Calculate diagonal for extended drawing (ensures coverage when rotated)
val diagonal = sqrt(width * width + height * height) val diagonal = sqrt(width * width + height * height)
val extendedHalf = diagonal val extendedHalf = diagonal
val outlineWidth = 4.dp.toPx()
val lineWidth = 2.dp.toPx()
val centerLineWidth = 3.dp.toPx()
val centerOutlineWidth = 5.dp.toPx()
rotate(angleDegrees, pivot = Offset(centerX, centerY)) { rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw blur zone indicators (top and bottom) // Draw blur zone indicators (top and bottom)
drawRect( drawRect(
@ -290,60 +268,32 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
size = Size(extendedHalf * 2, extendedHalf) size = Size(extendedHalf * 2, extendedHalf)
) )
// Draw focus zone boundary lines (outline first, then color) // Draw focus zone boundary lines
// Top boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight), start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight), end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = lineWidth, strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
// Bottom boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect pathEffect = dashEffect
) )
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight), start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight), end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = lineWidth, strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect pathEffect = dashEffect
) )
// Draw center focus line (outline + color) // Draw center focus line
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY),
strokeWidth = centerOutlineWidth
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY), start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY), end = Offset(centerX + extendedHalf, centerY),
strokeWidth = centerLineWidth strokeWidth = 3.dp.toPx()
) )
// Draw rotation indicator at center // Draw rotation indicator at center
val indicatorRadius = 30.dp.toPx() val indicatorRadius = 30.dp.toPx()
drawCircle(
color = outlineColor,
radius = indicatorRadius,
center = Offset(centerX, centerY),
style = Stroke(width = 4.dp.toPx())
)
drawCircle( drawCircle(
color = focusLineColor.copy(alpha = 0.5f), color = focusLineColor.copy(alpha = 0.5f),
radius = indicatorRadius, radius = indicatorRadius,
@ -353,12 +303,6 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
// Draw angle tick mark // Draw angle tick mark
val tickLength = 15.dp.toPx() val tickLength = 15.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength),
end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()),
strokeWidth = 5.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength), start = Offset(centerX, centerY - indicatorRadius + tickLength),
@ -370,7 +314,6 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
/** /**
* Draws the radial mode overlay (ellipse/circle). * Draws the radial mode overlay (ellipse/circle).
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
*/ */
private fun DrawScope.drawRadialOverlay(params: BlurParameters) { private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val width = size.width val width = size.width
@ -381,8 +324,9 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val focusRadius = height * params.size * 0.5f val focusRadius = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat()) val angleDegrees = params.angle * (180f / PI.toFloat())
val focusLineColor = AppColors.Accent // Colors for overlay
val outlineColor = AppColors.OverlayOutline val focusLineColor = Color(0xFFFFB300) // Amber
val blurZoneColor = Color(0x30FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f) val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f)
// Calculate ellipse dimensions based on aspect ratio // Calculate ellipse dimensions based on aspect ratio
@ -390,13 +334,7 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val ellipseHeight = focusRadius * 2 val ellipseHeight = focusRadius * 2
rotate(angleDegrees, pivot = Offset(centerX, centerY)) { rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw focus ellipse outline (outline + color) // Draw focus ellipse outline (inner boundary)
drawOval(
color = outlineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
size = Size(ellipseWidth, ellipseHeight),
style = Stroke(width = 5.dp.toPx())
)
drawOval( drawOval(
color = focusLineColor, color = focusLineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2), topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
@ -406,15 +344,6 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
// Draw outer blur boundary (with falloff) // Draw outer blur boundary (with falloff)
val outerScale = 1f + params.falloff val outerScale = 1f + params.falloff
drawOval(
color = outlineColor,
topLeft = Offset(
centerX - (ellipseWidth * outerScale) / 2,
centerY - (ellipseHeight * outerScale) / 2
),
size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale),
style = Stroke(width = 4.dp.toPx(), pathEffect = dashEffect)
)
drawOval( drawOval(
color = focusLineColor.copy(alpha = 0.5f), color = focusLineColor.copy(alpha = 0.5f),
topLeft = Offset( topLeft = Offset(
@ -425,26 +354,14 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect) style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect)
) )
// Draw center crosshair (outline + color) // Draw center crosshair
val crosshairSize = 20.dp.toPx() val crosshairSize = 20.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 4.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - crosshairSize, centerY), start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY), end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
) )
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - crosshairSize),
end = Offset(centerX, centerY + crosshairSize),
strokeWidth = 4.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - crosshairSize), start = Offset(centerX, centerY - crosshairSize),
@ -452,13 +369,7 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
) )
// Draw rotation indicator (small line at top of ellipse, outline + color) // Draw rotation indicator (small line at top of ellipse)
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),
end = Offset(centerX, centerY - ellipseHeight / 2 - 20.dp.toPx()),
strokeWidth = 5.dp.toPx()
)
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()), start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),

View file

@ -16,15 +16,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.ui.theme.AppColors
import kotlin.math.abs import kotlin.math.abs
/** /**
@ -71,9 +65,9 @@ private fun ZoomButton(
) { ) {
val backgroundColor by animateColorAsState( val backgroundColor by animateColorAsState(
targetValue = if (isSelected) { targetValue = if (isSelected) {
AppColors.Accent Color(0xFFFFB300) // Amber when selected
} else { } else {
AppColors.OverlayDark Color(0x80000000) // Semi-transparent black
}, },
label = "zoom_button_bg" label = "zoom_button_bg"
) )
@ -85,11 +79,10 @@ private fun ZoomButton(
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(44.dp)
.clip(CircleShape) .clip(CircleShape)
.background(backgroundColor) .background(backgroundColor)
.clickable(role = Role.Button, onClick = onClick) .clickable(onClick = onClick),
.semantics { selected = isSelected; contentDescription = "${preset.label}x zoom" },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@ -112,9 +105,8 @@ fun ZoomIndicator(
Box( Box(
modifier = modifier modifier = modifier
.clip(CircleShape) .clip(CircleShape)
.background(AppColors.OverlayDark) .background(Color(0x80000000))
.padding(horizontal = 12.dp, vertical = 6.dp) .padding(horizontal = 12.dp, vertical = 6.dp),
.semantics { contentDescription = "Current zoom: %.1fx".format(currentZoom) },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(

View file

@ -1,18 +0,0 @@
package no.naiv.tiltshift.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Centralized color definitions for the Tilt-Shift Camera app.
*/
object AppColors {
val Accent = Color(0xFFFFB300)
val OverlayDark = Color(0x80000000)
val OverlayDarker = Color(0xCC000000)
val Success = Color(0xFF4CAF50)
val Error = Color(0xFFF44336)
val OverlayLinearBlur = Color(0x40FFFFFF)
val OverlayRadialBlur = Color(0x30FFFFFF)
/** Dark outline behind overlay guide lines for visibility over bright scenes. */
val OverlayOutline = Color(0x80000000)
}

View file

@ -14,7 +14,6 @@ playServicesLocation = "21.3.0"
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui = { group = "androidx.compose.ui", name = "ui" }