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.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// Compose

View file

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

View file

@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.Executor
import java.util.concurrent.Executors
/**
* Manages CameraX camera setup and controls.
@ -57,9 +56,6 @@ class CameraManager(private val context: Context) {
private val _isFrontCamera = MutableStateFlow(false)
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 surfaceSize: Size = Size(1920, 1080)
private var lifecycleOwnerRef: LifecycleOwner? = null
@ -198,12 +194,10 @@ class CameraManager(private val context: Context) {
}
/**
* Gets the background 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).
* Gets the executor for image capture callbacks.
*/
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.graphics.Bitmap
import android.graphics.SurfaceTexture
import android.location.Location
import android.net.Uri
import android.opengl.GLSurfaceView
import android.util.Log
import android.view.Surface
@ -32,15 +34,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
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.FlipCameraAndroid
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.IconButton
import androidx.compose.material3.Slider
@ -53,96 +51,124 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.Image
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.LiveRegionMode
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.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import no.naiv.tiltshift.camera.CameraManager
import no.naiv.tiltshift.camera.ImageCaptureHandler
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
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.
* Uses CameraViewModel to survive configuration changes.
*/
@Composable
fun CameraScreen(
modifier: Modifier = Modifier,
viewModel: CameraViewModel = viewModel()
modifier: Modifier = Modifier
) {
val context = LocalContext.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 renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
// Collect ViewModel state
val blurParams by viewModel.blurParams.collectAsState()
val isCapturing by viewModel.isCapturing.collectAsState()
val isProcessing by viewModel.isProcessing.collectAsState()
val showSaveSuccess by viewModel.showSaveSuccess.collectAsState()
val showSaveError by viewModel.showSaveError.collectAsState()
val showControls by viewModel.showControls.collectAsState()
val lastSavedUri by viewModel.lastSavedUri.collectAsState()
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
val galleryBitmap by viewModel.galleryBitmap.collectAsState()
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) }
// Thumbnail state for last captured photo
var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
// 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 zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState()
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
val cameraError by viewModel.cameraManager.error.collectAsState()
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
var currentLocation by remember { mutableStateOf<Location?>(null) }
// Gallery picker
// Gallery picker: load image for interactive preview before processing
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) {
viewModel.loadGalleryImage(uri)
if (uri != null && !isCapturing && !isGalleryPreview) {
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) {
cameraError?.let { message ->
viewModel.showCameraError(message)
viewModel.cameraManager.clearError()
showSaveError = message
cameraManager.clearError()
delay(2000)
showSaveError = null
}
}
// Collect orientation updates
LaunchedEffect(Unit) {
viewModel.orientationDetector.orientationFlow().collectLatest { rotation ->
viewModel.updateRotation(rotation)
orientationDetector.orientationFlow().collectLatest { rotation ->
currentRotation = rotation
}
}
// Collect location updates
LaunchedEffect(Unit) {
viewModel.locationProvider.locationFlow().collectLatest { location ->
viewModel.updateLocation(location)
locationProvider.locationFlow().collectLatest { location ->
currentLocation = location
}
}
@ -161,14 +187,17 @@ fun CameraScreen(
// Start camera when surface texture is available
LaunchedEffect(surfaceTexture) {
surfaceTexture?.let {
viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
}
}
// Cleanup GL resources (ViewModel handles its own cleanup in onCleared)
// Cleanup
DisposableEffect(Unit) {
onDispose {
cameraManager.release()
renderer?.release()
lastThumbnailBitmap?.recycle()
galleryBitmap?.recycle()
}
}
@ -182,7 +211,7 @@ fun CameraScreen(
galleryBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Gallery image preview. Adjust tilt-shift parameters then tap Apply.",
contentDescription = "Gallery preview",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
@ -215,12 +244,13 @@ fun CameraScreen(
TiltShiftOverlay(
params = blurParams,
onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams)
blurParams = newParams
haptics.tick()
},
onZoomChange = { zoomDelta ->
if (!isGalleryPreview) {
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
viewModel.cameraManager.setZoom(newZoom)
cameraManager.setZoom(newZoom)
}
},
modifier = Modifier.fillMaxSize()
@ -239,6 +269,7 @@ fun CameraScreen(
verticalAlignment = Alignment.CenterVertically
) {
if (!isGalleryPreview) {
// Zoom indicator
ZoomIndicator(currentZoom = zoomRatio)
} else {
Spacer(modifier = Modifier.width(1.dp))
@ -249,32 +280,29 @@ fun CameraScreen(
// Camera flip button
IconButton(
onClick = {
viewModel.cameraManager.switchCamera()
viewModel.haptics.click()
cameraManager.switchCamera()
haptics.click()
}
) {
Icon(
imageVector = Icons.Default.FlipCameraAndroid,
contentDescription = "Switch between front and back camera",
contentDescription = "Switch Camera",
tint = Color.White
)
}
}
// Toggle controls button (tune icon instead of cryptic "Ctrl")
// Toggle controls button
IconButton(
onClick = {
viewModel.toggleControls()
viewModel.haptics.tick()
},
modifier = Modifier.semantics {
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
showControls = !showControls
haptics.tick()
}
) {
Icon(
imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune,
contentDescription = if (showControls) "Hide blur controls" else "Show blur controls",
tint = Color.White
Text(
text = if (showControls) "Hide" else "Ctrl",
color = Color.White,
fontSize = 12.sp
)
}
}
@ -290,8 +318,8 @@ fun CameraScreen(
ModeToggle(
currentMode = blurParams.mode,
onModeChange = { mode ->
viewModel.updateBlurParams(blurParams.copy(mode = mode))
viewModel.haptics.click()
blurParams = blurParams.copy(mode = mode)
haptics.click()
}
)
}
@ -309,9 +337,8 @@ fun CameraScreen(
ControlPanel(
params = blurParams,
onParamsChange = { newParams ->
viewModel.updateBlurParams(newParams)
},
onReset = { viewModel.resetBlurParams() }
blurParams = newParams
}
)
}
@ -325,93 +352,108 @@ fun CameraScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isGalleryPreview) {
// Gallery preview mode: Cancel | Apply (matched layout to camera mode)
// Gallery preview mode: Cancel | Apply
Row(
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(
onClick = { viewModel.cancelGalleryPreview() },
onClick = {
val oldBitmap = galleryBitmap
galleryBitmap = null
galleryImageUri = null
oldBitmap?.recycle()
},
modifier = Modifier
.size(52.dp)
.size(56.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
.semantics { contentDescription = "Cancel gallery import" }
.background(Color(0x80000000))
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
contentDescription = "Cancel",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
// Apply button (same 72dp as capture button for layout consistency)
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.border(4.dp, Color.White, CircleShape)
.clickable(
enabled = !isCapturing,
role = Role.Button,
onClick = { viewModel.applyGalleryEffect() }
// Apply button
IconButton(
onClick = {
val uri = galleryImageUri ?: return@IconButton
if (!isCapturing) {
isCapturing = true
haptics.heavyClick()
scope.launch {
val result = captureHandler.processExistingImage(
imageUri = uri,
blurParams = blurParams,
location = currentLocation
)
.semantics {
contentDescription = "Apply tilt-shift effect to gallery image"
if (isCapturing) stateDescription = "Processing"
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
}
}
val oldGalleryBitmap = galleryBitmap
galleryBitmap = null
galleryImageUri = null
oldGalleryBitmap?.recycle()
isCapturing = false
}
}
},
contentAlignment = Alignment.Center
) {
Box(
enabled = !isCapturing,
modifier = Modifier
.size(if (isCapturing) 48.dp else 60.dp)
.size(56.dp)
.clip(CircleShape)
.background(if (isCapturing) AppColors.Accent.copy(alpha = 0.5f) else AppColors.Accent),
contentAlignment = Alignment.Center
.background(Color(0xFFFFB300))
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.Black,
strokeWidth = 3.dp
)
} else {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
contentDescription = "Apply effect",
tint = Color.Black,
modifier = Modifier.size(28.dp)
)
}
}
}
// Spacer for visual symmetry
Spacer(modifier = Modifier.size(52.dp))
}
} else {
// Camera mode: Zoom presets + Gallery | Capture | Spacer
// Zoom presets (only show for back camera)
if (!isFrontCamera) {
ZoomControl(
currentZoom = zoomRatio,
minZoom = minZoom,
maxZoom = maxZoom,
onZoomSelected = { zoom ->
viewModel.cameraManager.setZoom(zoom)
viewModel.haptics.click()
cameraManager.setZoom(zoom)
haptics.click()
}
)
Spacer(modifier = Modifier.height(24.dp))
}
// Gallery button | Capture button | Spacer for symmetry
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// Gallery picker button (with background for discoverability)
// Gallery picker button
IconButton(
onClick = {
if (!isCapturing) {
@ -421,14 +463,11 @@ fun CameraScreen(
}
},
enabled = !isCapturing,
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(AppColors.OverlayDark)
modifier = Modifier.size(52.dp)
) {
Icon(
imageVector = Icons.Default.PhotoLibrary,
contentDescription = "Pick image from gallery",
contentDescription = "Pick from gallery",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
@ -437,8 +476,46 @@ fun CameraScreen(
// Capture button
CaptureButton(
isCapturing = isCapturing,
isProcessing = isProcessing,
onClick = { viewModel.capturePhoto() }
onClick = {
if (!isCapturing) {
isCapturing = true
haptics.heavyClick()
scope.launch {
val imageCapture = cameraManager.imageCapture
if (imageCapture != null) {
val result = captureHandler.capturePhoto(
imageCapture = imageCapture,
executor = cameraManager.getExecutor(),
blurParams = blurParams,
deviceRotation = currentRotation,
location = currentLocation,
isFrontCamera = 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
@ -469,32 +546,30 @@ fun CameraScreen(
.padding(bottom = 48.dp, end = 16.dp)
)
// Success indicator (announced to accessibility)
// Success indicator
AnimatedVisibility(
visible = showSaveSuccess,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.Center)
.semantics { liveRegion = LiveRegionMode.Polite }
modifier = Modifier.align(Alignment.Center)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(AppColors.Success),
.background(Color(0xFF4CAF50)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Photo saved successfully",
contentDescription = "Saved",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
}
// Error indicator (announced to accessibility)
// Error indicator
AnimatedVisibility(
visible = showSaveError != null,
enter = fadeIn(),
@ -502,18 +577,17 @@ fun CameraScreen(
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp)
.semantics { liveRegion = LiveRegionMode.Assertive }
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(AppColors.Error)
.background(Color(0xFFF44336))
.padding(24.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
contentDescription = "Error",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
@ -521,35 +595,10 @@ fun CameraScreen(
Text(
text = showSaveError ?: "Error",
color = Color.White,
fontSize = 14.sp,
maxLines = 3
fontSize = 14.sp
)
}
}
// 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(
modifier = modifier
.clip(RoundedCornerShape(20.dp))
.background(AppColors.OverlayDark)
.background(Color(0x80000000))
.padding(4.dp),
horizontalArrangement = Arrangement.Center
) {
@ -592,13 +641,9 @@ private fun ModeButton(
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(if (isSelected) AppColors.Accent else Color.Transparent)
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics {
stateDescription = if (isSelected) "Selected" else "Not selected"
contentDescription = "$text blur mode"
},
.background(if (isSelected) Color(0xFFFFB300) else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
@ -611,15 +656,14 @@ private fun ModeButton(
/**
* Control panel with sliders for blur parameters.
* Includes position/size/angle sliders as gesture alternatives for accessibility.
*/
@Composable
private fun ControlPanel(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
// Use rememberUpdatedState to avoid stale closure capture during slider drags
val currentParams by rememberUpdatedState(params)
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
@ -627,34 +671,15 @@ private fun ControlPanel(
modifier = modifier
.width(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(AppColors.OverlayDarker)
.background(Color(0xCC000000))
.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
SliderControl(
label = "Blur",
value = params.blurAmount,
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
)
@ -663,38 +688,19 @@ private fun ControlPanel(
label = "Falloff",
value = params.falloff,
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
formatValue = { "${(it * 100).toInt()}%" },
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)
if (params.mode == BlurMode.RADIAL) {
SliderControl(
label = "Shape",
value = params.aspectRatio,
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
formatValue = { "%.1f:1".format(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,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
onValueChange: (Float) -> Unit
) {
Column {
@ -717,7 +722,7 @@ private fun SliderControl(
fontSize = 12.sp
)
Text(
text = formatValue(value),
text = "${(value * 100).toInt()}%",
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp
)
@ -727,24 +732,21 @@ private fun SliderControl(
onValueChange = onValueChange,
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = AppColors.Accent,
activeTrackColor = AppColors.Accent,
thumbColor = Color(0xFFFFB300),
activeTrackColor = Color(0xFFFFB300),
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
),
modifier = Modifier
.height(24.dp)
.semantics { contentDescription = "$label: ${formatValue(value)}" }
modifier = Modifier.height(24.dp)
)
}
}
/**
* Capture button with processing indicator.
* Capture button with animation for capturing state.
*/
@Composable
private fun CaptureButton(
isCapturing: Boolean,
isProcessing: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@ -756,33 +758,16 @@ private fun CaptureButton(
.size(outerSize)
.clip(CircleShape)
.border(4.dp, Color.White, CircleShape)
.clickable(
enabled = !isCapturing,
role = Role.Button,
onClick = onClick
)
.semantics {
contentDescription = "Capture photo with tilt-shift effect"
if (isCapturing) stateDescription = "Processing photo"
},
.clickable(enabled = !isCapturing, onClick = onClick),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(innerSize)
.clip(CircleShape)
.background(if (isCapturing) AppColors.Accent else Color.White),
contentAlignment = Alignment.Center
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.Black,
strokeWidth = 3.dp
.background(if (isCapturing) Color(0xFFFFB300) else Color.White)
)
}
}
}
}
/**
@ -804,13 +789,13 @@ private fun LastPhotoThumbnail(
thumbnail?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Last captured photo. Tap to open in viewer.",
contentDescription = "Last captured photo",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(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.draw.clip
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.unit.dp
import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.camera.CameraLens
import no.naiv.tiltshift.ui.theme.AppColors
/**
* Lens selection UI for switching between camera lenses.
@ -65,9 +59,9 @@ private fun LensButton(
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) {
AppColors.Accent
Color(0xFFFFB300)
} else {
AppColors.OverlayDark
Color(0x80000000)
},
label = "lens_button_bg"
)
@ -82,8 +76,7 @@ private fun LensButton(
.size(48.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(role = Role.Button, onClick = onClick)
.semantics { selected = isSelected; contentDescription = "${lens.displayName} lens" },
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
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.input.pointer.PointerEventType
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 no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.ui.theme.AppColors
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
@ -42,16 +39,13 @@ private enum class GestureType {
DRAG_POSITION, // Single finger drag to move focus center
ROTATE, // Two-finger rotation
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)
private const val SIZE_SENSITIVITY = 0.3f
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.
*/
@ -76,16 +70,9 @@ fun TiltShiftOverlay(
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
val modeLabel = if (params.mode == BlurMode.LINEAR) "linear" else "radial"
Canvas(
modifier = modifier
.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) {
awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
@ -200,8 +187,6 @@ fun TiltShiftOverlay(
* - Very center (< 30% of focus size): Rotation
* - Near focus region (30% - 200% of focus size): Size adjustment
* - Far outside (> 200%): Camera zoom
*
* Focus size is clamped to a minimum pixel value to keep zones usable at small sizes.
*/
private fun determineGestureType(
centroid: Offset,
@ -211,8 +196,7 @@ private fun determineGestureType(
): GestureType {
val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY
// Clamp focus size to minimum to keep rotation zone reachable
val focusSize = maxOf(height * params.size * 0.5f, MIN_FOCUS_SIZE_PX)
val focusSize = height * params.size * 0.5f
val dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY
@ -252,7 +236,6 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
/**
* 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) {
val width = size.width
@ -263,20 +246,15 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat())
val focusLineColor = AppColors.Accent
val outlineColor = AppColors.OverlayOutline
val blurZoneColor = AppColors.OverlayLinearBlur
// Colors for overlay
val focusLineColor = Color(0xFFFFB300) // Amber
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
// Calculate diagonal for extended drawing (ensures coverage when rotated)
val diagonal = sqrt(width * width + height * height)
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)) {
// Draw blur zone indicators (top and bottom)
drawRect(
@ -290,60 +268,32 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
size = Size(extendedHalf * 2, extendedHalf)
)
// Draw focus zone boundary lines (outline first, then color)
// Top boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = outlineWidth,
pathEffect = dashEffect
)
// Draw focus zone boundary lines
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
strokeWidth = lineWidth,
pathEffect = dashEffect
)
// Bottom boundary
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = outlineWidth,
strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
strokeWidth = lineWidth,
strokeWidth = 2.dp.toPx(),
pathEffect = dashEffect
)
// Draw center focus line (outline + color)
drawLine(
color = outlineColor,
start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY),
strokeWidth = centerOutlineWidth
)
// Draw center focus line
drawLine(
color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY),
end = Offset(centerX + extendedHalf, centerY),
strokeWidth = centerLineWidth
strokeWidth = 3.dp.toPx()
)
// Draw rotation indicator at center
val indicatorRadius = 30.dp.toPx()
drawCircle(
color = outlineColor,
radius = indicatorRadius,
center = Offset(centerX, centerY),
style = Stroke(width = 4.dp.toPx())
)
drawCircle(
color = focusLineColor.copy(alpha = 0.5f),
radius = indicatorRadius,
@ -353,12 +303,6 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
// Draw angle tick mark
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(
color = focusLineColor,
start = Offset(centerX, centerY - indicatorRadius + tickLength),
@ -370,7 +314,6 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
/**
* 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) {
val width = size.width
@ -381,8 +324,9 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val focusRadius = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat())
val focusLineColor = AppColors.Accent
val outlineColor = AppColors.OverlayOutline
// 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
@ -390,13 +334,7 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
val ellipseHeight = focusRadius * 2
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw focus ellipse outline (outline + color)
drawOval(
color = outlineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
size = Size(ellipseWidth, ellipseHeight),
style = Stroke(width = 5.dp.toPx())
)
// Draw focus ellipse outline (inner boundary)
drawOval(
color = focusLineColor,
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
@ -406,15 +344,6 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
// Draw outer blur boundary (with 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(
color = focusLineColor.copy(alpha = 0.5f),
topLeft = Offset(
@ -425,26 +354,14 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect)
)
// Draw center crosshair (outline + color)
// Draw center crosshair
val crosshairSize = 20.dp.toPx()
drawLine(
color = outlineColor,
start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 4.dp.toPx()
)
drawLine(
color = focusLineColor,
start = Offset(centerX - crosshairSize, centerY),
end = Offset(centerX + crosshairSize, centerY),
strokeWidth = 2.dp.toPx()
)
drawLine(
color = outlineColor,
start = Offset(centerX, centerY - crosshairSize),
end = Offset(centerX, centerY + crosshairSize),
strokeWidth = 4.dp.toPx()
)
drawLine(
color = focusLineColor,
start = Offset(centerX, centerY - crosshairSize),
@ -452,13 +369,7 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
strokeWidth = 2.dp.toPx()
)
// Draw rotation indicator (small line at top of ellipse, outline + color)
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()
)
// Draw rotation indicator (small line at top of ellipse)
drawLine(
color = focusLineColor,
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.draw.clip
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.unit.dp
import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.ui.theme.AppColors
import kotlin.math.abs
/**
@ -71,9 +65,9 @@ private fun ZoomButton(
) {
val backgroundColor by animateColorAsState(
targetValue = if (isSelected) {
AppColors.Accent
Color(0xFFFFB300) // Amber when selected
} else {
AppColors.OverlayDark
Color(0x80000000) // Semi-transparent black
},
label = "zoom_button_bg"
)
@ -85,11 +79,10 @@ private fun ZoomButton(
Box(
modifier = Modifier
.size(48.dp)
.size(44.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(role = Role.Button, onClick = onClick)
.semantics { selected = isSelected; contentDescription = "${preset.label}x zoom" },
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(
@ -112,9 +105,8 @@ fun ZoomIndicator(
Box(
modifier = modifier
.clip(CircleShape)
.background(AppColors.OverlayDark)
.padding(horizontal = 12.dp, vertical = 6.dp)
.semantics { contentDescription = "Current zoom: %.1fx".format(currentZoom) },
.background(Color(0x80000000))
.padding(horizontal = 12.dp, vertical = 6.dp),
contentAlignment = Alignment.Center
) {
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-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-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-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }