diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7bbc0a..7c9c599 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/no/naiv/tiltshift/MainActivity.kt b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt index ab0cca3..9f8b95b 100644 --- a/app/src/main/java/no/naiv/tiltshift/MainActivity.kt +++ b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt @@ -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,31 +201,13 @@ 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 - ) - ) { - Text("Grant", color = Color.Black) - } + Button( + onClick = onRequest, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFFB300) + ) + ) { + 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 20104bd..3717693 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -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 = _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) } /** 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 f613107..f9d851e 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -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(null) } var renderer by remember { mutableStateOf(null) } var glSurfaceView 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() + 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) } 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(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() } - ) - .semantics { - contentDescription = "Apply tilt-shift effect to gallery image" - if (isCapturing) stateDescription = "Processing" - }, - contentAlignment = Alignment.Center - ) { - 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) - ) + // 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, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0xFFFFB300)) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Apply effect", + tint = Color.Black, + modifier = Modifier.size(28.dp) + ) } - - // Spacer for visual symmetry - Spacer(modifier = Modifier.size(52.dp)) } } else { // 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, - 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,32 +758,15 @@ 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) ) } } diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt deleted file mode 100644 index 89b61b1..0000000 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt +++ /dev/null @@ -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 = _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 eae4528..c2ca806 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt @@ -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( 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 d7e6e4a..8c84e76 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt @@ -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()), 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 226fa0b..a20cf47 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt @@ -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( 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 deleted file mode 100644 index d4a1ba4..0000000 --- a/app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt +++ /dev/null @@ -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) -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37e1882..cd52516 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }