diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c9c599..b7bbc0a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ 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 diff --git a/app/src/main/java/no/naiv/tiltshift/MainActivity.kt b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt index 9f8b95b..ab0cca3 100644 --- a/app/src/main/java/no/naiv/tiltshift/MainActivity.kt +++ b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt @@ -1,7 +1,10 @@ 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 @@ -25,6 +28,7 @@ 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 @@ -38,7 +42,9 @@ 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() { @@ -96,12 +102,17 @@ 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 + locationGranted = locationPermissions.allPermissionsGranted, + cameraPermanentlyDenied = cameraPermanentlyDenied ) } } @@ -113,7 +124,8 @@ private fun PermissionRequestScreen( onRequestCamera: () -> Unit, onRequestLocation: () -> Unit, cameraGranted: Boolean, - locationGranted: Boolean + locationGranted: Boolean, + cameraPermanentlyDenied: Boolean ) { Column( modifier = Modifier @@ -146,6 +158,7 @@ private fun PermissionRequestScreen( title = "Camera", description = "Required to take photos", isGranted = cameraGranted, + isPermanentlyDenied = cameraPermanentlyDenied, onRequest = onRequestCamera ) @@ -168,8 +181,10 @@ 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)) @@ -182,7 +197,7 @@ private fun PermissionItem( Icon( imageVector = icon, contentDescription = null, - tint = if (isGranted) Color(0xFF4CAF50) else Color(0xFFFFB300), + tint = if (isGranted) AppColors.Success else AppColors.Accent, modifier = Modifier.padding(bottom = 8.dp) ) @@ -201,13 +216,31 @@ private fun PermissionItem( if (!isGranted) { Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = onRequest, - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFFB300) - ) - ) { - Text("Grant", color = Color.Black) + 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 + ) + ) { + Text("Grant", color = Color.Black) + } } } } diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index 3717693..20104bd 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -19,6 +19,7 @@ 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. @@ -56,6 +57,9 @@ class CameraManager(private val context: Context) { private val _isFrontCamera = MutableStateFlow(false) val isFrontCamera: StateFlow = _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 @@ -194,10 +198,12 @@ class CameraManager(private val context: Context) { } /** - * Gets the executor for image capture callbacks. + * 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). */ fun getExecutor(): Executor { - return ContextCompat.getMainExecutor(context) + return captureExecutor } /** diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index f9d851e..f613107 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -3,8 +3,6 @@ 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 @@ -34,11 +32,15 @@ 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 @@ -51,124 +53,96 @@ 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.lifecycle.compose.LocalLifecycleOwner +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.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import kotlinx.coroutines.delay +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import no.naiv.tiltshift.camera.CameraManager -import no.naiv.tiltshift.camera.ImageCaptureHandler import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.TiltShiftRenderer -import no.naiv.tiltshift.storage.PhotoSaver -import no.naiv.tiltshift.storage.SaveResult -import no.naiv.tiltshift.util.HapticFeedback -import no.naiv.tiltshift.util.LocationProvider -import no.naiv.tiltshift.util.OrientationDetector +import no.naiv.tiltshift.ui.theme.AppColors /** * Main camera screen with tilt-shift controls. + * Uses CameraViewModel to survive configuration changes. */ @Composable fun CameraScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: CameraViewModel = viewModel() ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - val scope = rememberCoroutineScope() - // 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) } + // GL state (view-layer, not in ViewModel) var surfaceTexture by remember { mutableStateOf(null) } var renderer by remember { mutableStateOf(null) } var glSurfaceView by remember { mutableStateOf(null) } - var isCapturing by remember { mutableStateOf(false) } - var showSaveSuccess by remember { mutableStateOf(false) } - var showSaveError by remember { mutableStateOf(null) } - var showControls by remember { mutableStateOf(false) } - - // Thumbnail state for last captured photo - var lastSavedUri by remember { mutableStateOf(null) } - var lastThumbnailBitmap by remember { mutableStateOf(null) } - - // Gallery preview mode: non-null means we're previewing a gallery image - var galleryBitmap by remember { mutableStateOf(null) } - var galleryImageUri by remember { mutableStateOf(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() val isGalleryPreview = galleryBitmap != null - var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } - var currentLocation by remember { mutableStateOf(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() - // Gallery picker: load image for interactive preview before processing + // Gallery picker val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { 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 - } - } + if (uri != null) { + viewModel.loadGalleryImage(uri) } } - 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 + // Show camera errors LaunchedEffect(cameraError) { cameraError?.let { message -> - showSaveError = message - cameraManager.clearError() - delay(2000) - showSaveError = null + viewModel.showCameraError(message) + viewModel.cameraManager.clearError() } } // Collect orientation updates LaunchedEffect(Unit) { - orientationDetector.orientationFlow().collectLatest { rotation -> - currentRotation = rotation + viewModel.orientationDetector.orientationFlow().collectLatest { rotation -> + viewModel.updateRotation(rotation) } } // Collect location updates LaunchedEffect(Unit) { - locationProvider.locationFlow().collectLatest { location -> - currentLocation = location + viewModel.locationProvider.locationFlow().collectLatest { location -> + viewModel.updateLocation(location) } } @@ -187,17 +161,14 @@ fun CameraScreen( // Start camera when surface texture is available LaunchedEffect(surfaceTexture) { surfaceTexture?.let { - cameraManager.startCamera(lifecycleOwner) { surfaceTexture } + viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture } } } - // Cleanup + // Cleanup GL resources (ViewModel handles its own cleanup in onCleared) DisposableEffect(Unit) { onDispose { - cameraManager.release() renderer?.release() - lastThumbnailBitmap?.recycle() - galleryBitmap?.recycle() } } @@ -211,7 +182,7 @@ fun CameraScreen( galleryBitmap?.let { bmp -> Image( bitmap = bmp.asImageBitmap(), - contentDescription = "Gallery preview", + contentDescription = "Gallery image preview. Adjust tilt-shift parameters then tap Apply.", contentScale = ContentScale.Fit, modifier = Modifier .fillMaxSize() @@ -244,13 +215,12 @@ fun CameraScreen( TiltShiftOverlay( params = blurParams, onParamsChange = { newParams -> - blurParams = newParams - haptics.tick() + viewModel.updateBlurParams(newParams) }, onZoomChange = { zoomDelta -> if (!isGalleryPreview) { val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom) - cameraManager.setZoom(newZoom) + viewModel.cameraManager.setZoom(newZoom) } }, modifier = Modifier.fillMaxSize() @@ -269,7 +239,6 @@ fun CameraScreen( verticalAlignment = Alignment.CenterVertically ) { if (!isGalleryPreview) { - // Zoom indicator ZoomIndicator(currentZoom = zoomRatio) } else { Spacer(modifier = Modifier.width(1.dp)) @@ -280,29 +249,32 @@ fun CameraScreen( // Camera flip button IconButton( onClick = { - cameraManager.switchCamera() - haptics.click() + viewModel.cameraManager.switchCamera() + viewModel.haptics.click() } ) { Icon( imageVector = Icons.Default.FlipCameraAndroid, - contentDescription = "Switch Camera", + contentDescription = "Switch between front and back camera", tint = Color.White ) } } - // Toggle controls button + // Toggle controls button (tune icon instead of cryptic "Ctrl") IconButton( onClick = { - showControls = !showControls - haptics.tick() + viewModel.toggleControls() + viewModel.haptics.tick() + }, + modifier = Modifier.semantics { + stateDescription = if (showControls) "Controls visible" else "Controls hidden" } ) { - Text( - text = if (showControls) "Hide" else "Ctrl", - color = Color.White, - fontSize = 12.sp + Icon( + imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune, + contentDescription = if (showControls) "Hide blur controls" else "Show blur controls", + tint = Color.White ) } } @@ -318,8 +290,8 @@ fun CameraScreen( ModeToggle( currentMode = blurParams.mode, onModeChange = { mode -> - blurParams = blurParams.copy(mode = mode) - haptics.click() + viewModel.updateBlurParams(blurParams.copy(mode = mode)) + viewModel.haptics.click() } ) } @@ -337,8 +309,9 @@ fun CameraScreen( ControlPanel( params = blurParams, onParamsChange = { newParams -> - blurParams = newParams - } + viewModel.updateBlurParams(newParams) + }, + onReset = { viewModel.resetBlurParams() } ) } @@ -352,108 +325,93 @@ fun CameraScreen( horizontalAlignment = Alignment.CenterHorizontally ) { if (isGalleryPreview) { - // Gallery preview mode: Cancel | Apply + // Gallery preview mode: Cancel | Apply (matched layout to camera mode) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(48.dp) + horizontalArrangement = Arrangement.spacedBy(24.dp) ) { - // Cancel button + // Cancel button (same 52dp as gallery button for layout consistency) IconButton( - onClick = { - val oldBitmap = galleryBitmap - galleryBitmap = null - galleryImageUri = null - oldBitmap?.recycle() - }, + onClick = { viewModel.cancelGalleryPreview() }, modifier = Modifier - .size(56.dp) + .size(52.dp) .clip(CircleShape) - .background(Color(0x80000000)) + .background(AppColors.OverlayDark) + .semantics { contentDescription = "Cancel gallery import" } ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Cancel", + contentDescription = null, tint = Color.White, modifier = Modifier.size(28.dp) ) } - // 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 - ) - 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 - } - } - }, - enabled = !isCapturing, + // Apply button (same 72dp as capture button for layout consistency) + Box( modifier = Modifier - .size(56.dp) + .size(72.dp) .clip(CircleShape) - .background(Color(0xFFFFB300)) + .border(4.dp, Color.White, CircleShape) + .clickable( + enabled = !isCapturing, + role = Role.Button, + onClick = { viewModel.applyGalleryEffect() } + ) + .semantics { + contentDescription = "Apply tilt-shift effect to gallery image" + if (isCapturing) stateDescription = "Processing" + }, + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Apply effect", - tint = Color.Black, - modifier = Modifier.size(28.dp) - ) + Box( + modifier = Modifier + .size(if (isCapturing) 48.dp else 60.dp) + .clip(CircleShape) + .background(if (isCapturing) AppColors.Accent.copy(alpha = 0.5f) else AppColors.Accent), + contentAlignment = Alignment.Center + ) { + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.Black, + strokeWidth = 3.dp + ) + } else { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + 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 -> - cameraManager.setZoom(zoom) - haptics.click() + viewModel.cameraManager.setZoom(zoom) + viewModel.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 + // Gallery picker button (with background for discoverability) IconButton( onClick = { if (!isCapturing) { @@ -463,11 +421,14 @@ fun CameraScreen( } }, enabled = !isCapturing, - modifier = Modifier.size(52.dp) + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(AppColors.OverlayDark) ) { Icon( imageVector = Icons.Default.PhotoLibrary, - contentDescription = "Pick from gallery", + contentDescription = "Pick image from gallery", tint = Color.White, modifier = Modifier.size(28.dp) ) @@ -476,46 +437,8 @@ fun CameraScreen( // Capture button CaptureButton( isCapturing = isCapturing, - 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 - } - } - } + isProcessing = isProcessing, + onClick = { viewModel.capturePhoto() } ) // Spacer for visual symmetry with gallery button @@ -546,30 +469,32 @@ fun CameraScreen( .padding(bottom = 48.dp, end = 16.dp) ) - // Success indicator + // Success indicator (announced to accessibility) AnimatedVisibility( visible = showSaveSuccess, enter = fadeIn(), exit = fadeOut(), - modifier = Modifier.align(Alignment.Center) + modifier = Modifier + .align(Alignment.Center) + .semantics { liveRegion = LiveRegionMode.Polite } ) { Box( modifier = Modifier .size(80.dp) .clip(CircleShape) - .background(Color(0xFF4CAF50)), + .background(AppColors.Success), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Saved", + contentDescription = "Photo saved successfully", tint = Color.White, modifier = Modifier.size(48.dp) ) } } - // Error indicator + // Error indicator (announced to accessibility) AnimatedVisibility( visible = showSaveError != null, enter = fadeIn(), @@ -577,17 +502,18 @@ 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(Color(0xFFF44336)) + .background(AppColors.Error) .padding(24.dp) ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Error", + contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp) ) @@ -595,10 +521,35 @@ fun CameraScreen( Text( text = showSaveError ?: "Error", 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) + ) + } + } + } } } @@ -614,7 +565,7 @@ private fun ModeToggle( Row( modifier = modifier .clip(RoundedCornerShape(20.dp)) - .background(Color(0x80000000)) + .background(AppColors.OverlayDark) .padding(4.dp), horizontalArrangement = Arrangement.Center ) { @@ -641,9 +592,13 @@ private fun ModeButton( Box( modifier = Modifier .clip(RoundedCornerShape(16.dp)) - .background(if (isSelected) Color(0xFFFFB300) else Color.Transparent) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp), + .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" + }, contentAlignment = Alignment.Center ) { Text( @@ -656,14 +611,15 @@ 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) @@ -671,15 +627,34 @@ private fun ControlPanel( modifier = modifier .width(200.dp) .clip(RoundedCornerShape(16.dp)) - .background(Color(0xCC000000)) + .background(AppColors.OverlayDarker) .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(12.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)) } ) @@ -688,19 +663,38 @@ 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)) } + ) } } @@ -709,6 +703,7 @@ private fun SliderControl( label: String, value: Float, valueRange: ClosedFloatingPointRange, + formatValue: (Float) -> String = { "${(it * 100).toInt()}%" }, onValueChange: (Float) -> Unit ) { Column { @@ -722,7 +717,7 @@ private fun SliderControl( fontSize = 12.sp ) Text( - text = "${(value * 100).toInt()}%", + text = formatValue(value), color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp ) @@ -732,21 +727,24 @@ private fun SliderControl( onValueChange = onValueChange, valueRange = valueRange, colors = SliderDefaults.colors( - thumbColor = Color(0xFFFFB300), - activeTrackColor = Color(0xFFFFB300), + thumbColor = AppColors.Accent, + activeTrackColor = AppColors.Accent, inactiveTrackColor = Color.White.copy(alpha = 0.3f) ), - modifier = Modifier.height(24.dp) + modifier = Modifier + .height(24.dp) + .semantics { contentDescription = "$label: ${formatValue(value)}" } ) } } /** - * Capture button with animation for capturing state. + * Capture button with processing indicator. */ @Composable private fun CaptureButton( isCapturing: Boolean, + isProcessing: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -758,15 +756,32 @@ private fun CaptureButton( .size(outerSize) .clip(CircleShape) .border(4.dp, Color.White, CircleShape) - .clickable(enabled = !isCapturing, onClick = onClick), + .clickable( + enabled = !isCapturing, + role = Role.Button, + onClick = onClick + ) + .semantics { + contentDescription = "Capture photo with tilt-shift effect" + if (isCapturing) stateDescription = "Processing photo" + }, contentAlignment = Alignment.Center ) { Box( modifier = Modifier .size(innerSize) .clip(CircleShape) - .background(if (isCapturing) Color(0xFFFFB300) else Color.White) - ) + .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 + ) + } + } } } @@ -789,13 +804,13 @@ private fun LastPhotoThumbnail( thumbnail?.let { bmp -> Image( bitmap = bmp.asImageBitmap(), - contentDescription = "Last captured photo", + contentDescription = "Last captured photo. Tap to open in viewer.", contentScale = ContentScale.Crop, modifier = Modifier .size(52.dp) .clip(RoundedCornerShape(10.dp)) .border(2.dp, Color.White, RoundedCornerShape(10.dp)) - .clickable(onClick = onTap) + .clickable(role = Role.Button, onClick = onTap) ) } } diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt new file mode 100644 index 0000000..89b61b1 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt @@ -0,0 +1,209 @@ +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 = _blurParams.asStateFlow() + + // Capture state + private val _isCapturing = MutableStateFlow(false) + val isCapturing: StateFlow = _isCapturing.asStateFlow() + + private val _showSaveSuccess = MutableStateFlow(false) + val showSaveSuccess: StateFlow = _showSaveSuccess.asStateFlow() + + private val _showSaveError = MutableStateFlow(null) + val showSaveError: StateFlow = _showSaveError.asStateFlow() + + private val _showControls = MutableStateFlow(false) + val showControls: StateFlow = _showControls.asStateFlow() + + // Thumbnail state + private val _lastSavedUri = MutableStateFlow(null) + val lastSavedUri: StateFlow = _lastSavedUri.asStateFlow() + + private val _lastThumbnailBitmap = MutableStateFlow(null) + val lastThumbnailBitmap: StateFlow = _lastThumbnailBitmap.asStateFlow() + + // Gallery preview state + private val _galleryBitmap = MutableStateFlow(null) + val galleryBitmap: StateFlow = _galleryBitmap.asStateFlow() + + private val _galleryImageUri = MutableStateFlow(null) + val galleryImageUri: StateFlow = _galleryImageUri.asStateFlow() + + val isGalleryPreview: Boolean get() = _galleryBitmap.value != null + + // Device state + private val _currentRotation = MutableStateFlow(Surface.ROTATION_0) + val currentRotation: StateFlow = _currentRotation.asStateFlow() + + private val _currentLocation = MutableStateFlow(null) + val currentLocation: StateFlow = _currentLocation.asStateFlow() + + // Processing indicator + private val _isProcessing = MutableStateFlow(false) + val isProcessing: StateFlow = _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() + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt index c2ca806..eae4528 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt @@ -16,10 +16,16 @@ 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. @@ -59,9 +65,9 @@ private fun LensButton( ) { val backgroundColor by animateColorAsState( targetValue = if (isSelected) { - Color(0xFFFFB300) + AppColors.Accent } else { - Color(0x80000000) + AppColors.OverlayDark }, label = "lens_button_bg" ) @@ -76,7 +82,8 @@ private fun LensButton( .size(48.dp) .clip(CircleShape) .background(backgroundColor) - .clickable(onClick = onClick), + .clickable(role = Role.Button, onClick = onClick) + .semantics { selected = isSelected; contentDescription = "${lens.displayName} lens" }, contentAlignment = Alignment.Center ) { Text( diff --git a/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt index 8c84e76..d7e6e4a 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt @@ -22,9 +22,12 @@ 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 @@ -39,13 +42,16 @@ 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 in center to zoom camera + PINCH_ZOOM // Pinch far outside 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. */ @@ -70,9 +76,16 @@ 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) @@ -187,6 +200,8 @@ 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, @@ -196,7 +211,8 @@ private fun determineGestureType( ): GestureType { val focusCenterX = width * params.positionX val focusCenterY = height * params.positionY - val focusSize = height * params.size * 0.5f + // Clamp focus size to minimum to keep rotation zone reachable + val focusSize = maxOf(height * params.size * 0.5f, MIN_FOCUS_SIZE_PX) val dx = centroid.x - focusCenterX val dy = centroid.y - focusCenterY @@ -236,6 +252,7 @@ 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 @@ -246,15 +263,20 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) { val focusHalfHeight = height * params.size * 0.5f val angleDegrees = params.angle * (180f / PI.toFloat()) - // Colors for overlay - val focusLineColor = Color(0xFFFFB300) // Amber - val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white + val focusLineColor = AppColors.Accent + val outlineColor = AppColors.OverlayOutline + val blurZoneColor = AppColors.OverlayLinearBlur 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( @@ -268,32 +290,60 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) { size = Size(extendedHalf * 2, extendedHalf) ) - // Draw focus zone boundary lines + // 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 + ) drawLine( color = focusLineColor, start = Offset(centerX - extendedHalf, centerY - focusHalfHeight), end = Offset(centerX + extendedHalf, centerY - focusHalfHeight), - strokeWidth = 2.dp.toPx(), + strokeWidth = lineWidth, + pathEffect = dashEffect + ) + // Bottom boundary + drawLine( + color = outlineColor, + start = Offset(centerX - extendedHalf, centerY + focusHalfHeight), + end = Offset(centerX + extendedHalf, centerY + focusHalfHeight), + strokeWidth = outlineWidth, pathEffect = dashEffect ) drawLine( color = focusLineColor, start = Offset(centerX - extendedHalf, centerY + focusHalfHeight), end = Offset(centerX + extendedHalf, centerY + focusHalfHeight), - strokeWidth = 2.dp.toPx(), + strokeWidth = lineWidth, pathEffect = dashEffect ) - // Draw center focus line + // Draw center focus line (outline + color) + drawLine( + color = outlineColor, + start = Offset(centerX - extendedHalf, centerY), + end = Offset(centerX + extendedHalf, centerY), + strokeWidth = centerOutlineWidth + ) drawLine( color = focusLineColor, start = Offset(centerX - extendedHalf, centerY), end = Offset(centerX + extendedHalf, centerY), - strokeWidth = 3.dp.toPx() + strokeWidth = centerLineWidth ) // 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, @@ -303,6 +353,12 @@ 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), @@ -314,6 +370,7 @@ 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 @@ -324,9 +381,8 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) { val focusRadius = height * params.size * 0.5f val angleDegrees = params.angle * (180f / PI.toFloat()) - // Colors for overlay - val focusLineColor = Color(0xFFFFB300) // Amber - val blurZoneColor = Color(0x30FFFFFF) // Semi-transparent white + val focusLineColor = AppColors.Accent + val outlineColor = AppColors.OverlayOutline val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f) // Calculate ellipse dimensions based on aspect ratio @@ -334,7 +390,13 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) { val ellipseHeight = focusRadius * 2 rotate(angleDegrees, pivot = Offset(centerX, centerY)) { - // Draw focus ellipse outline (inner boundary) + // 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()) + ) drawOval( color = focusLineColor, topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2), @@ -344,6 +406,15 @@ 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( @@ -354,14 +425,26 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) { style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect) ) - // Draw center crosshair + // Draw center crosshair (outline + color) 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), @@ -369,7 +452,13 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) { strokeWidth = 2.dp.toPx() ) - // Draw rotation indicator (small line at top of ellipse) + // 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() + ) drawLine( color = focusLineColor, start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()), diff --git a/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt b/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt index a20cf47..226fa0b 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt @@ -16,9 +16,15 @@ 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 /** @@ -65,9 +71,9 @@ private fun ZoomButton( ) { val backgroundColor by animateColorAsState( targetValue = if (isSelected) { - Color(0xFFFFB300) // Amber when selected + AppColors.Accent } else { - Color(0x80000000) // Semi-transparent black + AppColors.OverlayDark }, label = "zoom_button_bg" ) @@ -79,10 +85,11 @@ private fun ZoomButton( Box( modifier = Modifier - .size(44.dp) + .size(48.dp) .clip(CircleShape) .background(backgroundColor) - .clickable(onClick = onClick), + .clickable(role = Role.Button, onClick = onClick) + .semantics { selected = isSelected; contentDescription = "${preset.label}x zoom" }, contentAlignment = Alignment.Center ) { Text( @@ -105,8 +112,9 @@ fun ZoomIndicator( Box( modifier = modifier .clip(CircleShape) - .background(Color(0x80000000)) - .padding(horizontal = 12.dp, vertical = 6.dp), + .background(AppColors.OverlayDark) + .padding(horizontal = 12.dp, vertical = 6.dp) + .semantics { contentDescription = "Current zoom: %.1fx".format(currentZoom) }, contentAlignment = Alignment.Center ) { Text( diff --git a/app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt b/app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt new file mode 100644 index 0000000..d4a1ba4 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt @@ -0,0 +1,18 @@ +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) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd52516..37e1882 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ 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" }