Compare commits
6 commits
5e08fb9c13
...
efe406f0b0
| Author | SHA1 | Date | |
|---|---|---|---|
| efe406f0b0 | |||
| ee2b11941a | |||
| 527c8fd0bb | |||
| 04b61fdd9e | |||
| 72156f4e5d | |||
| 6a1d66bd4b |
10 changed files with 650 additions and 263 deletions
|
|
@ -80,6 +80,7 @@ dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
// Compose
|
// Compose
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package no.naiv.tiltshift
|
package no.naiv.tiltshift
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
@ -25,6 +28,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
@ -38,7 +42,9 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import com.google.accompanist.permissions.shouldShowRationale
|
||||||
import no.naiv.tiltshift.ui.CameraScreen
|
import no.naiv.tiltshift.ui.CameraScreen
|
||||||
|
import no.naiv.tiltshift.ui.theme.AppColors
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
|
@ -96,12 +102,17 @@ private fun TiltShiftApp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
// Permanently denied: not granted AND rationale not shown
|
||||||
|
val cameraPermanentlyDenied = !cameraPermission.status.isGranted &&
|
||||||
|
!cameraPermission.status.shouldShowRationale
|
||||||
|
|
||||||
// Show permission request UI
|
// Show permission request UI
|
||||||
PermissionRequestScreen(
|
PermissionRequestScreen(
|
||||||
onRequestCamera = { cameraPermission.launchPermissionRequest() },
|
onRequestCamera = { cameraPermission.launchPermissionRequest() },
|
||||||
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
|
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
|
||||||
cameraGranted = cameraPermission.status.isGranted,
|
cameraGranted = cameraPermission.status.isGranted,
|
||||||
locationGranted = locationPermissions.allPermissionsGranted
|
locationGranted = locationPermissions.allPermissionsGranted,
|
||||||
|
cameraPermanentlyDenied = cameraPermanentlyDenied
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +124,8 @@ private fun PermissionRequestScreen(
|
||||||
onRequestCamera: () -> Unit,
|
onRequestCamera: () -> Unit,
|
||||||
onRequestLocation: () -> Unit,
|
onRequestLocation: () -> Unit,
|
||||||
cameraGranted: Boolean,
|
cameraGranted: Boolean,
|
||||||
locationGranted: Boolean
|
locationGranted: Boolean,
|
||||||
|
cameraPermanentlyDenied: Boolean
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -146,6 +158,7 @@ private fun PermissionRequestScreen(
|
||||||
title = "Camera",
|
title = "Camera",
|
||||||
description = "Required to take photos",
|
description = "Required to take photos",
|
||||||
isGranted = cameraGranted,
|
isGranted = cameraGranted,
|
||||||
|
isPermanentlyDenied = cameraPermanentlyDenied,
|
||||||
onRequest = onRequestCamera
|
onRequest = onRequestCamera
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -168,8 +181,10 @@ private fun PermissionItem(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
isGranted: Boolean,
|
isGranted: Boolean,
|
||||||
|
isPermanentlyDenied: Boolean = false,
|
||||||
onRequest: () -> Unit
|
onRequest: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
|
@ -182,7 +197,7 @@ private fun PermissionItem(
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (isGranted) Color(0xFF4CAF50) else Color(0xFFFFB300),
|
tint = if (isGranted) AppColors.Success else AppColors.Accent,
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -201,13 +216,31 @@ private fun PermissionItem(
|
||||||
|
|
||||||
if (!isGranted) {
|
if (!isGranted) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Button(
|
if (isPermanentlyDenied) {
|
||||||
onClick = onRequest,
|
Button(
|
||||||
colors = ButtonDefaults.buttonColors(
|
onClick = {
|
||||||
containerColor = Color(0xFFFFB300)
|
context.startActivity(
|
||||||
)
|
Intent(
|
||||||
) {
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
Text("Grant", color = Color.Black)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages CameraX camera setup and controls.
|
* Manages CameraX camera setup and controls.
|
||||||
|
|
@ -56,6 +57,9 @@ class CameraManager(private val context: Context) {
|
||||||
private val _isFrontCamera = MutableStateFlow(false)
|
private val _isFrontCamera = MutableStateFlow(false)
|
||||||
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
|
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
|
||||||
|
|
||||||
|
/** Background executor for image capture callbacks to avoid blocking the main thread. */
|
||||||
|
private val captureExecutor: Executor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
|
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
|
||||||
private var surfaceSize: Size = Size(1920, 1080)
|
private var surfaceSize: Size = Size(1920, 1080)
|
||||||
private var lifecycleOwnerRef: LifecycleOwner? = null
|
private var lifecycleOwnerRef: LifecycleOwner? = null
|
||||||
|
|
@ -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 {
|
fun getExecutor(): Executor {
|
||||||
return ContextCompat.getMainExecutor(context)
|
return captureExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ package no.naiv.tiltshift.ui
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
import android.location.Location
|
|
||||||
import android.net.Uri
|
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
|
@ -34,11 +32,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.FlipCameraAndroid
|
import androidx.compose.material.icons.filled.FlipCameraAndroid
|
||||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||||
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
|
@ -51,124 +53,96 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import kotlinx.coroutines.delay
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import no.naiv.tiltshift.camera.CameraManager
|
|
||||||
import no.naiv.tiltshift.camera.ImageCaptureHandler
|
|
||||||
import no.naiv.tiltshift.effect.BlurMode
|
import no.naiv.tiltshift.effect.BlurMode
|
||||||
import no.naiv.tiltshift.effect.BlurParameters
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
||||||
import no.naiv.tiltshift.storage.PhotoSaver
|
import no.naiv.tiltshift.ui.theme.AppColors
|
||||||
import no.naiv.tiltshift.storage.SaveResult
|
|
||||||
import no.naiv.tiltshift.util.HapticFeedback
|
|
||||||
import no.naiv.tiltshift.util.LocationProvider
|
|
||||||
import no.naiv.tiltshift.util.OrientationDetector
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main camera screen with tilt-shift controls.
|
* Main camera screen with tilt-shift controls.
|
||||||
|
* Uses CameraViewModel to survive configuration changes.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CameraScreen(
|
fun CameraScreen(
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: CameraViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
// Camera and effect state
|
// GL state (view-layer, not in ViewModel)
|
||||||
val cameraManager = remember { CameraManager(context) }
|
|
||||||
val photoSaver = remember { PhotoSaver(context) }
|
|
||||||
val captureHandler = remember { ImageCaptureHandler(context, photoSaver) }
|
|
||||||
val haptics = remember { HapticFeedback(context) }
|
|
||||||
val orientationDetector = remember { OrientationDetector(context) }
|
|
||||||
val locationProvider = remember { LocationProvider(context) }
|
|
||||||
|
|
||||||
// State
|
|
||||||
var blurParams by remember { mutableStateOf(BlurParameters.DEFAULT) }
|
|
||||||
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
|
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
|
||||||
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
|
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
|
||||||
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
|
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
|
||||||
|
|
||||||
var isCapturing by remember { mutableStateOf(false) }
|
// Collect ViewModel state
|
||||||
var showSaveSuccess by remember { mutableStateOf(false) }
|
val blurParams by viewModel.blurParams.collectAsState()
|
||||||
var showSaveError by remember { mutableStateOf<String?>(null) }
|
val isCapturing by viewModel.isCapturing.collectAsState()
|
||||||
var showControls by remember { mutableStateOf(false) }
|
val isProcessing by viewModel.isProcessing.collectAsState()
|
||||||
|
val showSaveSuccess by viewModel.showSaveSuccess.collectAsState()
|
||||||
// Thumbnail state for last captured photo
|
val showSaveError by viewModel.showSaveError.collectAsState()
|
||||||
var lastSavedUri by remember { mutableStateOf<Uri?>(null) }
|
val showControls by viewModel.showControls.collectAsState()
|
||||||
var lastThumbnailBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
val lastSavedUri by viewModel.lastSavedUri.collectAsState()
|
||||||
|
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
|
||||||
// Gallery preview mode: non-null means we're previewing a gallery image
|
val galleryBitmap by viewModel.galleryBitmap.collectAsState()
|
||||||
var galleryBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
var galleryImageUri by remember { mutableStateOf<Uri?>(null) }
|
|
||||||
val isGalleryPreview = galleryBitmap != null
|
val isGalleryPreview = galleryBitmap != null
|
||||||
|
|
||||||
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
|
val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState()
|
||||||
var currentLocation by remember { mutableStateOf<Location?>(null) }
|
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(
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.PickVisualMedia()
|
contract = ActivityResultContracts.PickVisualMedia()
|
||||||
) { uri ->
|
) { uri ->
|
||||||
if (uri != null && !isCapturing && !isGalleryPreview) {
|
if (uri != null) {
|
||||||
scope.launch {
|
viewModel.loadGalleryImage(uri)
|
||||||
val bitmap = captureHandler.loadGalleryImage(uri)
|
|
||||||
if (bitmap != null) {
|
|
||||||
galleryBitmap = bitmap
|
|
||||||
galleryImageUri = uri
|
|
||||||
} else {
|
|
||||||
haptics.error()
|
|
||||||
showSaveError = "Failed to load image"
|
|
||||||
delay(2000)
|
|
||||||
showSaveError = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val zoomRatio by cameraManager.zoomRatio.collectAsState()
|
// Show camera errors
|
||||||
val minZoom by cameraManager.minZoomRatio.collectAsState()
|
|
||||||
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
|
|
||||||
val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
|
|
||||||
val cameraError by cameraManager.error.collectAsState()
|
|
||||||
|
|
||||||
// Show camera errors via the existing error UI
|
|
||||||
LaunchedEffect(cameraError) {
|
LaunchedEffect(cameraError) {
|
||||||
cameraError?.let { message ->
|
cameraError?.let { message ->
|
||||||
showSaveError = message
|
viewModel.showCameraError(message)
|
||||||
cameraManager.clearError()
|
viewModel.cameraManager.clearError()
|
||||||
delay(2000)
|
|
||||||
showSaveError = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect orientation updates
|
// Collect orientation updates
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
orientationDetector.orientationFlow().collectLatest { rotation ->
|
viewModel.orientationDetector.orientationFlow().collectLatest { rotation ->
|
||||||
currentRotation = rotation
|
viewModel.updateRotation(rotation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect location updates
|
// Collect location updates
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
locationProvider.locationFlow().collectLatest { location ->
|
viewModel.locationProvider.locationFlow().collectLatest { location ->
|
||||||
currentLocation = location
|
viewModel.updateLocation(location)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,17 +161,14 @@ fun CameraScreen(
|
||||||
// Start camera when surface texture is available
|
// Start camera when surface texture is available
|
||||||
LaunchedEffect(surfaceTexture) {
|
LaunchedEffect(surfaceTexture) {
|
||||||
surfaceTexture?.let {
|
surfaceTexture?.let {
|
||||||
cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
|
viewModel.cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup GL resources (ViewModel handles its own cleanup in onCleared)
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
cameraManager.release()
|
|
||||||
renderer?.release()
|
renderer?.release()
|
||||||
lastThumbnailBitmap?.recycle()
|
|
||||||
galleryBitmap?.recycle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,7 +182,7 @@ fun CameraScreen(
|
||||||
galleryBitmap?.let { bmp ->
|
galleryBitmap?.let { bmp ->
|
||||||
Image(
|
Image(
|
||||||
bitmap = bmp.asImageBitmap(),
|
bitmap = bmp.asImageBitmap(),
|
||||||
contentDescription = "Gallery preview",
|
contentDescription = "Gallery image preview. Adjust tilt-shift parameters then tap Apply.",
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -244,13 +215,12 @@ fun CameraScreen(
|
||||||
TiltShiftOverlay(
|
TiltShiftOverlay(
|
||||||
params = blurParams,
|
params = blurParams,
|
||||||
onParamsChange = { newParams ->
|
onParamsChange = { newParams ->
|
||||||
blurParams = newParams
|
viewModel.updateBlurParams(newParams)
|
||||||
haptics.tick()
|
|
||||||
},
|
},
|
||||||
onZoomChange = { zoomDelta ->
|
onZoomChange = { zoomDelta ->
|
||||||
if (!isGalleryPreview) {
|
if (!isGalleryPreview) {
|
||||||
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
||||||
cameraManager.setZoom(newZoom)
|
viewModel.cameraManager.setZoom(newZoom)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
|
|
@ -269,7 +239,6 @@ fun CameraScreen(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (!isGalleryPreview) {
|
if (!isGalleryPreview) {
|
||||||
// Zoom indicator
|
|
||||||
ZoomIndicator(currentZoom = zoomRatio)
|
ZoomIndicator(currentZoom = zoomRatio)
|
||||||
} else {
|
} else {
|
||||||
Spacer(modifier = Modifier.width(1.dp))
|
Spacer(modifier = Modifier.width(1.dp))
|
||||||
|
|
@ -280,29 +249,32 @@ fun CameraScreen(
|
||||||
// Camera flip button
|
// Camera flip button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
cameraManager.switchCamera()
|
viewModel.cameraManager.switchCamera()
|
||||||
haptics.click()
|
viewModel.haptics.click()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.FlipCameraAndroid,
|
imageVector = Icons.Default.FlipCameraAndroid,
|
||||||
contentDescription = "Switch Camera",
|
contentDescription = "Switch between front and back camera",
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle controls button
|
// Toggle controls button (tune icon instead of cryptic "Ctrl")
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showControls = !showControls
|
viewModel.toggleControls()
|
||||||
haptics.tick()
|
viewModel.haptics.tick()
|
||||||
|
},
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
stateDescription = if (showControls) "Controls visible" else "Controls hidden"
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = if (showControls) "Hide" else "Ctrl",
|
imageVector = if (showControls) Icons.Default.Close else Icons.Default.Tune,
|
||||||
color = Color.White,
|
contentDescription = if (showControls) "Hide blur controls" else "Show blur controls",
|
||||||
fontSize = 12.sp
|
tint = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,8 +290,8 @@ fun CameraScreen(
|
||||||
ModeToggle(
|
ModeToggle(
|
||||||
currentMode = blurParams.mode,
|
currentMode = blurParams.mode,
|
||||||
onModeChange = { mode ->
|
onModeChange = { mode ->
|
||||||
blurParams = blurParams.copy(mode = mode)
|
viewModel.updateBlurParams(blurParams.copy(mode = mode))
|
||||||
haptics.click()
|
viewModel.haptics.click()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -337,8 +309,9 @@ fun CameraScreen(
|
||||||
ControlPanel(
|
ControlPanel(
|
||||||
params = blurParams,
|
params = blurParams,
|
||||||
onParamsChange = { newParams ->
|
onParamsChange = { newParams ->
|
||||||
blurParams = newParams
|
viewModel.updateBlurParams(newParams)
|
||||||
}
|
},
|
||||||
|
onReset = { viewModel.resetBlurParams() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,108 +325,93 @@ fun CameraScreen(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
if (isGalleryPreview) {
|
if (isGalleryPreview) {
|
||||||
// Gallery preview mode: Cancel | Apply
|
// Gallery preview mode: Cancel | Apply (matched layout to camera mode)
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
IconButton(
|
||||||
onClick = {
|
onClick = { viewModel.cancelGalleryPreview() },
|
||||||
val oldBitmap = galleryBitmap
|
|
||||||
galleryBitmap = null
|
|
||||||
galleryImageUri = null
|
|
||||||
oldBitmap?.recycle()
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(56.dp)
|
.size(52.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0x80000000))
|
.background(AppColors.OverlayDark)
|
||||||
|
.semantics { contentDescription = "Cancel gallery import" }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = "Cancel",
|
contentDescription = null,
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(28.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply button
|
// Apply button (same 72dp as capture button for layout consistency)
|
||||||
IconButton(
|
Box(
|
||||||
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
|
modifier = Modifier
|
||||||
.size(56.dp)
|
.size(72.dp)
|
||||||
.clip(CircleShape)
|
.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(
|
Box(
|
||||||
imageVector = Icons.Default.Check,
|
modifier = Modifier
|
||||||
contentDescription = "Apply effect",
|
.size(if (isCapturing) 48.dp else 60.dp)
|
||||||
tint = Color.Black,
|
.clip(CircleShape)
|
||||||
modifier = Modifier.size(28.dp)
|
.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 {
|
} else {
|
||||||
// Camera mode: Zoom presets + Gallery | Capture | Spacer
|
// Camera mode: Zoom presets + Gallery | Capture | Spacer
|
||||||
// Zoom presets (only show for back camera)
|
|
||||||
if (!isFrontCamera) {
|
if (!isFrontCamera) {
|
||||||
ZoomControl(
|
ZoomControl(
|
||||||
currentZoom = zoomRatio,
|
currentZoom = zoomRatio,
|
||||||
minZoom = minZoom,
|
minZoom = minZoom,
|
||||||
maxZoom = maxZoom,
|
maxZoom = maxZoom,
|
||||||
onZoomSelected = { zoom ->
|
onZoomSelected = { zoom ->
|
||||||
cameraManager.setZoom(zoom)
|
viewModel.cameraManager.setZoom(zoom)
|
||||||
haptics.click()
|
viewModel.haptics.click()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gallery button | Capture button | Spacer for symmetry
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
) {
|
) {
|
||||||
// Gallery picker button
|
// Gallery picker button (with background for discoverability)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (!isCapturing) {
|
if (!isCapturing) {
|
||||||
|
|
@ -463,11 +421,14 @@ fun CameraScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isCapturing,
|
enabled = !isCapturing,
|
||||||
modifier = Modifier.size(52.dp)
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(AppColors.OverlayDark)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.PhotoLibrary,
|
imageVector = Icons.Default.PhotoLibrary,
|
||||||
contentDescription = "Pick from gallery",
|
contentDescription = "Pick image from gallery",
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(28.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
)
|
||||||
|
|
@ -476,46 +437,8 @@ fun CameraScreen(
|
||||||
// Capture button
|
// Capture button
|
||||||
CaptureButton(
|
CaptureButton(
|
||||||
isCapturing = isCapturing,
|
isCapturing = isCapturing,
|
||||||
onClick = {
|
isProcessing = isProcessing,
|
||||||
if (!isCapturing) {
|
onClick = { viewModel.capturePhoto() }
|
||||||
isCapturing = true
|
|
||||||
haptics.heavyClick()
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
val imageCapture = cameraManager.imageCapture
|
|
||||||
if (imageCapture != null) {
|
|
||||||
val result = captureHandler.capturePhoto(
|
|
||||||
imageCapture = imageCapture,
|
|
||||||
executor = cameraManager.getExecutor(),
|
|
||||||
blurParams = blurParams,
|
|
||||||
deviceRotation = currentRotation,
|
|
||||||
location = currentLocation,
|
|
||||||
isFrontCamera = isFrontCamera
|
|
||||||
)
|
|
||||||
|
|
||||||
when (result) {
|
|
||||||
is SaveResult.Success -> {
|
|
||||||
haptics.success()
|
|
||||||
val oldThumb = lastThumbnailBitmap
|
|
||||||
lastThumbnailBitmap = result.thumbnail
|
|
||||||
lastSavedUri = result.uri
|
|
||||||
oldThumb?.recycle()
|
|
||||||
showSaveSuccess = true
|
|
||||||
delay(1500)
|
|
||||||
showSaveSuccess = false
|
|
||||||
}
|
|
||||||
is SaveResult.Error -> {
|
|
||||||
haptics.error()
|
|
||||||
showSaveError = result.message
|
|
||||||
delay(2000)
|
|
||||||
showSaveError = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isCapturing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Spacer for visual symmetry with gallery button
|
// Spacer for visual symmetry with gallery button
|
||||||
|
|
@ -546,30 +469,32 @@ fun CameraScreen(
|
||||||
.padding(bottom = 48.dp, end = 16.dp)
|
.padding(bottom = 48.dp, end = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Success indicator
|
// Success indicator (announced to accessibility)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSaveSuccess,
|
visible = showSaveSuccess,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.semantics { liveRegion = LiveRegionMode.Polite }
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(80.dp)
|
.size(80.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0xFF4CAF50)),
|
.background(AppColors.Success),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Check,
|
imageVector = Icons.Default.Check,
|
||||||
contentDescription = "Saved",
|
contentDescription = "Photo saved successfully",
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error indicator
|
// Error indicator (announced to accessibility)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSaveError != null,
|
visible = showSaveError != null,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
|
|
@ -577,17 +502,18 @@ fun CameraScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.padding(32.dp)
|
.padding(32.dp)
|
||||||
|
.semantics { liveRegion = LiveRegionMode.Assertive }
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(Color(0xFFF44336))
|
.background(AppColors.Error)
|
||||||
.padding(24.dp)
|
.padding(24.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = "Error",
|
contentDescription = null,
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
)
|
)
|
||||||
|
|
@ -595,10 +521,35 @@ fun CameraScreen(
|
||||||
Text(
|
Text(
|
||||||
text = showSaveError ?: "Error",
|
text = showSaveError ?: "Error",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 14.sp
|
fontSize = 14.sp,
|
||||||
|
maxLines = 3
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Processing overlay
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isProcessing,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
) {
|
||||||
|
if (!showSaveSuccess) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(AppColors.OverlayDark),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = AppColors.Accent,
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -614,7 +565,7 @@ private fun ModeToggle(
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clip(RoundedCornerShape(20.dp))
|
.clip(RoundedCornerShape(20.dp))
|
||||||
.background(Color(0x80000000))
|
.background(AppColors.OverlayDark)
|
||||||
.padding(4.dp),
|
.padding(4.dp),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
|
|
@ -641,9 +592,13 @@ private fun ModeButton(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(if (isSelected) Color(0xFFFFB300) else Color.Transparent)
|
.background(if (isSelected) AppColors.Accent else Color.Transparent)
|
||||||
.clickable(onClick = onClick)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.semantics {
|
||||||
|
stateDescription = if (isSelected) "Selected" else "Not selected"
|
||||||
|
contentDescription = "$text blur mode"
|
||||||
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -656,14 +611,15 @@ private fun ModeButton(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Control panel with sliders for blur parameters.
|
* Control panel with sliders for blur parameters.
|
||||||
|
* Includes position/size/angle sliders as gesture alternatives for accessibility.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ControlPanel(
|
private fun ControlPanel(
|
||||||
params: BlurParameters,
|
params: BlurParameters,
|
||||||
onParamsChange: (BlurParameters) -> Unit,
|
onParamsChange: (BlurParameters) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
// Use rememberUpdatedState to avoid stale closure capture during slider drags
|
|
||||||
val currentParams by rememberUpdatedState(params)
|
val currentParams by rememberUpdatedState(params)
|
||||||
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
|
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
|
||||||
|
|
||||||
|
|
@ -671,15 +627,34 @@ private fun ControlPanel(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(200.dp)
|
.width(200.dp)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(Color(0xCC000000))
|
.background(AppColors.OverlayDarker)
|
||||||
.padding(16.dp),
|
.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
|
// Blur intensity slider
|
||||||
SliderControl(
|
SliderControl(
|
||||||
label = "Blur",
|
label = "Blur",
|
||||||
value = params.blurAmount,
|
value = params.blurAmount,
|
||||||
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
|
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
|
||||||
|
formatValue = { "${(it * 100).toInt()}%" },
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
|
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -688,19 +663,38 @@ private fun ControlPanel(
|
||||||
label = "Falloff",
|
label = "Falloff",
|
||||||
value = params.falloff,
|
value = params.falloff,
|
||||||
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
|
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
|
||||||
|
formatValue = { "${(it * 100).toInt()}%" },
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
|
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Size slider (gesture alternative for pinch-to-resize)
|
||||||
|
SliderControl(
|
||||||
|
label = "Size",
|
||||||
|
value = params.size,
|
||||||
|
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
|
||||||
|
formatValue = { "${(it * 100).toInt()}%" },
|
||||||
|
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
|
||||||
|
)
|
||||||
|
|
||||||
// Aspect ratio slider (radial mode only)
|
// Aspect ratio slider (radial mode only)
|
||||||
if (params.mode == BlurMode.RADIAL) {
|
if (params.mode == BlurMode.RADIAL) {
|
||||||
SliderControl(
|
SliderControl(
|
||||||
label = "Shape",
|
label = "Shape",
|
||||||
value = params.aspectRatio,
|
value = params.aspectRatio,
|
||||||
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
|
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
|
||||||
|
formatValue = { "%.1f:1".format(it) },
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Angle slider (gesture alternative for two-finger rotate)
|
||||||
|
SliderControl(
|
||||||
|
label = "Angle",
|
||||||
|
value = params.angle,
|
||||||
|
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
|
||||||
|
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
|
||||||
|
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -709,6 +703,7 @@ private fun SliderControl(
|
||||||
label: String,
|
label: String,
|
||||||
value: Float,
|
value: Float,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
|
||||||
onValueChange: (Float) -> Unit
|
onValueChange: (Float) -> Unit
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
|
@ -722,7 +717,7 @@ private fun SliderControl(
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${(value * 100).toInt()}%",
|
text = formatValue(value),
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
|
|
@ -732,21 +727,24 @@ private fun SliderControl(
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
valueRange = valueRange,
|
valueRange = valueRange,
|
||||||
colors = SliderDefaults.colors(
|
colors = SliderDefaults.colors(
|
||||||
thumbColor = Color(0xFFFFB300),
|
thumbColor = AppColors.Accent,
|
||||||
activeTrackColor = Color(0xFFFFB300),
|
activeTrackColor = AppColors.Accent,
|
||||||
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
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
|
@Composable
|
||||||
private fun CaptureButton(
|
private fun CaptureButton(
|
||||||
isCapturing: Boolean,
|
isCapturing: Boolean,
|
||||||
|
isProcessing: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -758,15 +756,32 @@ private fun CaptureButton(
|
||||||
.size(outerSize)
|
.size(outerSize)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.border(4.dp, Color.White, 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
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(innerSize)
|
.size(innerSize)
|
||||||
.clip(CircleShape)
|
.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 ->
|
thumbnail?.let { bmp ->
|
||||||
Image(
|
Image(
|
||||||
bitmap = bmp.asImageBitmap(),
|
bitmap = bmp.asImageBitmap(),
|
||||||
contentDescription = "Last captured photo",
|
contentDescription = "Last captured photo. Tap to open in viewer.",
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(52.dp)
|
.size(52.dp)
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
|
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
|
||||||
.clickable(onClick = onTap)
|
.clickable(role = Role.Button, onClick = onTap)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
209
app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt
Normal file
209
app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt
Normal file
|
|
@ -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<BlurParameters> = _blurParams.asStateFlow()
|
||||||
|
|
||||||
|
// Capture state
|
||||||
|
private val _isCapturing = MutableStateFlow(false)
|
||||||
|
val isCapturing: StateFlow<Boolean> = _isCapturing.asStateFlow()
|
||||||
|
|
||||||
|
private val _showSaveSuccess = MutableStateFlow(false)
|
||||||
|
val showSaveSuccess: StateFlow<Boolean> = _showSaveSuccess.asStateFlow()
|
||||||
|
|
||||||
|
private val _showSaveError = MutableStateFlow<String?>(null)
|
||||||
|
val showSaveError: StateFlow<String?> = _showSaveError.asStateFlow()
|
||||||
|
|
||||||
|
private val _showControls = MutableStateFlow(false)
|
||||||
|
val showControls: StateFlow<Boolean> = _showControls.asStateFlow()
|
||||||
|
|
||||||
|
// Thumbnail state
|
||||||
|
private val _lastSavedUri = MutableStateFlow<Uri?>(null)
|
||||||
|
val lastSavedUri: StateFlow<Uri?> = _lastSavedUri.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastThumbnailBitmap = MutableStateFlow<Bitmap?>(null)
|
||||||
|
val lastThumbnailBitmap: StateFlow<Bitmap?> = _lastThumbnailBitmap.asStateFlow()
|
||||||
|
|
||||||
|
// Gallery preview state
|
||||||
|
private val _galleryBitmap = MutableStateFlow<Bitmap?>(null)
|
||||||
|
val galleryBitmap: StateFlow<Bitmap?> = _galleryBitmap.asStateFlow()
|
||||||
|
|
||||||
|
private val _galleryImageUri = MutableStateFlow<Uri?>(null)
|
||||||
|
val galleryImageUri: StateFlow<Uri?> = _galleryImageUri.asStateFlow()
|
||||||
|
|
||||||
|
val isGalleryPreview: Boolean get() = _galleryBitmap.value != null
|
||||||
|
|
||||||
|
// Device state
|
||||||
|
private val _currentRotation = MutableStateFlow(Surface.ROTATION_0)
|
||||||
|
val currentRotation: StateFlow<Int> = _currentRotation.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentLocation = MutableStateFlow<Location?>(null)
|
||||||
|
val currentLocation: StateFlow<Location?> = _currentLocation.asStateFlow()
|
||||||
|
|
||||||
|
// Processing indicator
|
||||||
|
private val _isProcessing = MutableStateFlow(false)
|
||||||
|
val isProcessing: StateFlow<Boolean> = _isProcessing.asStateFlow()
|
||||||
|
|
||||||
|
fun updateBlurParams(params: BlurParameters) {
|
||||||
|
_blurParams.value = params
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetBlurParams() {
|
||||||
|
_blurParams.value = BlurParameters.DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleControls() {
|
||||||
|
_showControls.value = !_showControls.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRotation(rotation: Int) {
|
||||||
|
_currentRotation.value = rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLocation(location: Location?) {
|
||||||
|
_currentLocation.value = location
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadGalleryImage(uri: Uri) {
|
||||||
|
if (_isCapturing.value || isGalleryPreview) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isProcessing.value = true
|
||||||
|
val bitmap = captureHandler.loadGalleryImage(uri)
|
||||||
|
_isProcessing.value = false
|
||||||
|
if (bitmap != null) {
|
||||||
|
_galleryBitmap.value = bitmap
|
||||||
|
_galleryImageUri.value = uri
|
||||||
|
} else {
|
||||||
|
haptics.error()
|
||||||
|
showError("Failed to load image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelGalleryPreview() {
|
||||||
|
val old = _galleryBitmap.value
|
||||||
|
_galleryBitmap.value = null
|
||||||
|
_galleryImageUri.value = null
|
||||||
|
old?.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyGalleryEffect() {
|
||||||
|
val uri = _galleryImageUri.value ?: return
|
||||||
|
if (_isCapturing.value) return
|
||||||
|
_isCapturing.value = true
|
||||||
|
_isProcessing.value = true
|
||||||
|
haptics.heavyClick()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = captureHandler.processExistingImage(
|
||||||
|
imageUri = uri,
|
||||||
|
blurParams = _blurParams.value,
|
||||||
|
location = _currentLocation.value
|
||||||
|
)
|
||||||
|
handleSaveResult(result)
|
||||||
|
cancelGalleryPreview()
|
||||||
|
_isCapturing.value = false
|
||||||
|
_isProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun capturePhoto() {
|
||||||
|
if (_isCapturing.value) return
|
||||||
|
val imageCapture = cameraManager.imageCapture ?: return
|
||||||
|
_isCapturing.value = true
|
||||||
|
_isProcessing.value = true
|
||||||
|
haptics.heavyClick()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = captureHandler.capturePhoto(
|
||||||
|
imageCapture = imageCapture,
|
||||||
|
executor = cameraManager.getExecutor(),
|
||||||
|
blurParams = _blurParams.value,
|
||||||
|
deviceRotation = _currentRotation.value,
|
||||||
|
location = _currentLocation.value,
|
||||||
|
isFrontCamera = cameraManager.isFrontCamera.value
|
||||||
|
)
|
||||||
|
handleSaveResult(result)
|
||||||
|
_isCapturing.value = false
|
||||||
|
_isProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSaveResult(result: SaveResult) {
|
||||||
|
when (result) {
|
||||||
|
is SaveResult.Success -> {
|
||||||
|
haptics.success()
|
||||||
|
val oldThumb = _lastThumbnailBitmap.value
|
||||||
|
_lastThumbnailBitmap.value = result.thumbnail
|
||||||
|
_lastSavedUri.value = result.uri
|
||||||
|
oldThumb?.recycle()
|
||||||
|
viewModelScope.launch {
|
||||||
|
_showSaveSuccess.value = true
|
||||||
|
kotlinx.coroutines.delay(1500)
|
||||||
|
_showSaveSuccess.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SaveResult.Error -> {
|
||||||
|
haptics.error()
|
||||||
|
showError(result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(message: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_showSaveError.value = message
|
||||||
|
kotlinx.coroutines.delay(2000)
|
||||||
|
_showSaveError.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showCameraError(message: String) {
|
||||||
|
showError(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
cameraManager.release()
|
||||||
|
_lastThumbnailBitmap.value?.recycle()
|
||||||
|
_galleryBitmap.value?.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,10 +16,16 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import no.naiv.tiltshift.camera.CameraLens
|
import no.naiv.tiltshift.camera.CameraLens
|
||||||
|
import no.naiv.tiltshift.ui.theme.AppColors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lens selection UI for switching between camera lenses.
|
* Lens selection UI for switching between camera lenses.
|
||||||
|
|
@ -59,9 +65,9 @@ private fun LensButton(
|
||||||
) {
|
) {
|
||||||
val backgroundColor by animateColorAsState(
|
val backgroundColor by animateColorAsState(
|
||||||
targetValue = if (isSelected) {
|
targetValue = if (isSelected) {
|
||||||
Color(0xFFFFB300)
|
AppColors.Accent
|
||||||
} else {
|
} else {
|
||||||
Color(0x80000000)
|
AppColors.OverlayDark
|
||||||
},
|
},
|
||||||
label = "lens_button_bg"
|
label = "lens_button_bg"
|
||||||
)
|
)
|
||||||
|
|
@ -76,7 +82,8 @@ private fun LensButton(
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = onClick),
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.semantics { selected = isSelected; contentDescription = "${lens.displayName} lens" },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,12 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
import androidx.compose.ui.input.pointer.PointerEventType
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import no.naiv.tiltshift.effect.BlurMode
|
import no.naiv.tiltshift.effect.BlurMode
|
||||||
import no.naiv.tiltshift.effect.BlurParameters
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
|
import no.naiv.tiltshift.ui.theme.AppColors
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.atan2
|
import kotlin.math.atan2
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
|
|
@ -39,13 +42,16 @@ private enum class GestureType {
|
||||||
DRAG_POSITION, // Single finger drag to move focus center
|
DRAG_POSITION, // Single finger drag to move focus center
|
||||||
ROTATE, // Two-finger rotation
|
ROTATE, // Two-finger rotation
|
||||||
PINCH_SIZE, // Pinch near blur edges to resize
|
PINCH_SIZE, // Pinch near blur edges to resize
|
||||||
PINCH_ZOOM // Pinch in center to zoom camera
|
PINCH_ZOOM // Pinch far outside to zoom camera
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sensitivity factor for size pinch (lower = less sensitive)
|
// Sensitivity factor for size pinch (lower = less sensitive)
|
||||||
private const val SIZE_SENSITIVITY = 0.3f
|
private const val SIZE_SENSITIVITY = 0.3f
|
||||||
private const val ZOOM_SENSITIVITY = 0.5f
|
private const val ZOOM_SENSITIVITY = 0.5f
|
||||||
|
|
||||||
|
/** Minimum focus size (in px) for gesture zones to ensure usable touch targets. */
|
||||||
|
private const val MIN_FOCUS_SIZE_PX = 150f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the angle between two touch points.
|
* Calculates the angle between two touch points.
|
||||||
*/
|
*/
|
||||||
|
|
@ -70,9 +76,16 @@ fun TiltShiftOverlay(
|
||||||
|
|
||||||
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
|
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
|
||||||
|
|
||||||
|
val modeLabel = if (params.mode == BlurMode.LINEAR) "linear" else "radial"
|
||||||
|
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.semantics {
|
||||||
|
contentDescription = "Tilt-shift overlay: $modeLabel mode. " +
|
||||||
|
"Drag to move focus. Pinch near edges to resize. " +
|
||||||
|
"Pinch near center to rotate. Use sliders in controls panel for alternative adjustment."
|
||||||
|
}
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
|
@ -187,6 +200,8 @@ fun TiltShiftOverlay(
|
||||||
* - Very center (< 30% of focus size): Rotation
|
* - Very center (< 30% of focus size): Rotation
|
||||||
* - Near focus region (30% - 200% of focus size): Size adjustment
|
* - Near focus region (30% - 200% of focus size): Size adjustment
|
||||||
* - Far outside (> 200%): Camera zoom
|
* - Far outside (> 200%): Camera zoom
|
||||||
|
*
|
||||||
|
* Focus size is clamped to a minimum pixel value to keep zones usable at small sizes.
|
||||||
*/
|
*/
|
||||||
private fun determineGestureType(
|
private fun determineGestureType(
|
||||||
centroid: Offset,
|
centroid: Offset,
|
||||||
|
|
@ -196,7 +211,8 @@ private fun determineGestureType(
|
||||||
): GestureType {
|
): GestureType {
|
||||||
val focusCenterX = width * params.positionX
|
val focusCenterX = width * params.positionX
|
||||||
val focusCenterY = height * params.positionY
|
val focusCenterY = height * params.positionY
|
||||||
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 dx = centroid.x - focusCenterX
|
||||||
val dy = centroid.y - focusCenterY
|
val dy = centroid.y - focusCenterY
|
||||||
|
|
@ -236,6 +252,7 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws the linear mode overlay (horizontal band with rotation).
|
* Draws the linear mode overlay (horizontal band with rotation).
|
||||||
|
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
|
||||||
*/
|
*/
|
||||||
private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
|
private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
|
||||||
val width = size.width
|
val width = size.width
|
||||||
|
|
@ -246,15 +263,20 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
|
||||||
val focusHalfHeight = height * params.size * 0.5f
|
val focusHalfHeight = height * params.size * 0.5f
|
||||||
val angleDegrees = params.angle * (180f / PI.toFloat())
|
val angleDegrees = params.angle * (180f / PI.toFloat())
|
||||||
|
|
||||||
// Colors for overlay
|
val focusLineColor = AppColors.Accent
|
||||||
val focusLineColor = Color(0xFFFFB300) // Amber
|
val outlineColor = AppColors.OverlayOutline
|
||||||
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
|
val blurZoneColor = AppColors.OverlayLinearBlur
|
||||||
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
|
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
|
||||||
|
|
||||||
// Calculate diagonal for extended drawing (ensures coverage when rotated)
|
// Calculate diagonal for extended drawing (ensures coverage when rotated)
|
||||||
val diagonal = sqrt(width * width + height * height)
|
val diagonal = sqrt(width * width + height * height)
|
||||||
val extendedHalf = diagonal
|
val extendedHalf = diagonal
|
||||||
|
|
||||||
|
val outlineWidth = 4.dp.toPx()
|
||||||
|
val lineWidth = 2.dp.toPx()
|
||||||
|
val centerLineWidth = 3.dp.toPx()
|
||||||
|
val centerOutlineWidth = 5.dp.toPx()
|
||||||
|
|
||||||
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
|
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
|
||||||
// Draw blur zone indicators (top and bottom)
|
// Draw blur zone indicators (top and bottom)
|
||||||
drawRect(
|
drawRect(
|
||||||
|
|
@ -268,32 +290,60 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
|
||||||
size = Size(extendedHalf * 2, extendedHalf)
|
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(
|
drawLine(
|
||||||
color = focusLineColor,
|
color = focusLineColor,
|
||||||
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
|
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
|
||||||
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
|
end = Offset(centerX + extendedHalf, centerY - focusHalfHeight),
|
||||||
strokeWidth = 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
|
pathEffect = dashEffect
|
||||||
)
|
)
|
||||||
drawLine(
|
drawLine(
|
||||||
color = focusLineColor,
|
color = focusLineColor,
|
||||||
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
|
start = Offset(centerX - extendedHalf, centerY + focusHalfHeight),
|
||||||
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
|
end = Offset(centerX + extendedHalf, centerY + focusHalfHeight),
|
||||||
strokeWidth = 2.dp.toPx(),
|
strokeWidth = lineWidth,
|
||||||
pathEffect = dashEffect
|
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(
|
drawLine(
|
||||||
color = focusLineColor,
|
color = focusLineColor,
|
||||||
start = Offset(centerX - extendedHalf, centerY),
|
start = Offset(centerX - extendedHalf, centerY),
|
||||||
end = Offset(centerX + extendedHalf, centerY),
|
end = Offset(centerX + extendedHalf, centerY),
|
||||||
strokeWidth = 3.dp.toPx()
|
strokeWidth = centerLineWidth
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw rotation indicator at center
|
// Draw rotation indicator at center
|
||||||
val indicatorRadius = 30.dp.toPx()
|
val indicatorRadius = 30.dp.toPx()
|
||||||
|
drawCircle(
|
||||||
|
color = outlineColor,
|
||||||
|
radius = indicatorRadius,
|
||||||
|
center = Offset(centerX, centerY),
|
||||||
|
style = Stroke(width = 4.dp.toPx())
|
||||||
|
)
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = focusLineColor.copy(alpha = 0.5f),
|
color = focusLineColor.copy(alpha = 0.5f),
|
||||||
radius = indicatorRadius,
|
radius = indicatorRadius,
|
||||||
|
|
@ -303,6 +353,12 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
|
||||||
|
|
||||||
// Draw angle tick mark
|
// Draw angle tick mark
|
||||||
val tickLength = 15.dp.toPx()
|
val tickLength = 15.dp.toPx()
|
||||||
|
drawLine(
|
||||||
|
color = outlineColor,
|
||||||
|
start = Offset(centerX, centerY - indicatorRadius + tickLength),
|
||||||
|
end = Offset(centerX, centerY - indicatorRadius - 5.dp.toPx()),
|
||||||
|
strokeWidth = 5.dp.toPx()
|
||||||
|
)
|
||||||
drawLine(
|
drawLine(
|
||||||
color = focusLineColor,
|
color = focusLineColor,
|
||||||
start = Offset(centerX, centerY - indicatorRadius + tickLength),
|
start = Offset(centerX, centerY - indicatorRadius + tickLength),
|
||||||
|
|
@ -314,6 +370,7 @@ private fun DrawScope.drawLinearOverlay(params: BlurParameters) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws the radial mode overlay (ellipse/circle).
|
* Draws the radial mode overlay (ellipse/circle).
|
||||||
|
* All guide lines are drawn with a dark outline first for visibility over bright scenes.
|
||||||
*/
|
*/
|
||||||
private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
|
private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
|
||||||
val width = size.width
|
val width = size.width
|
||||||
|
|
@ -324,9 +381,8 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
|
||||||
val focusRadius = height * params.size * 0.5f
|
val focusRadius = height * params.size * 0.5f
|
||||||
val angleDegrees = params.angle * (180f / PI.toFloat())
|
val angleDegrees = params.angle * (180f / PI.toFloat())
|
||||||
|
|
||||||
// Colors for overlay
|
val focusLineColor = AppColors.Accent
|
||||||
val focusLineColor = Color(0xFFFFB300) // Amber
|
val outlineColor = AppColors.OverlayOutline
|
||||||
val blurZoneColor = Color(0x30FFFFFF) // Semi-transparent white
|
|
||||||
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f)
|
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f)
|
||||||
|
|
||||||
// Calculate ellipse dimensions based on aspect ratio
|
// Calculate ellipse dimensions based on aspect ratio
|
||||||
|
|
@ -334,7 +390,13 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
|
||||||
val ellipseHeight = focusRadius * 2
|
val ellipseHeight = focusRadius * 2
|
||||||
|
|
||||||
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
|
rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
|
||||||
// Draw focus ellipse outline (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(
|
drawOval(
|
||||||
color = focusLineColor,
|
color = focusLineColor,
|
||||||
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
|
topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2),
|
||||||
|
|
@ -344,6 +406,15 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
|
||||||
|
|
||||||
// Draw outer blur boundary (with falloff)
|
// Draw outer blur boundary (with falloff)
|
||||||
val outerScale = 1f + params.falloff
|
val outerScale = 1f + params.falloff
|
||||||
|
drawOval(
|
||||||
|
color = outlineColor,
|
||||||
|
topLeft = Offset(
|
||||||
|
centerX - (ellipseWidth * outerScale) / 2,
|
||||||
|
centerY - (ellipseHeight * outerScale) / 2
|
||||||
|
),
|
||||||
|
size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale),
|
||||||
|
style = Stroke(width = 4.dp.toPx(), pathEffect = dashEffect)
|
||||||
|
)
|
||||||
drawOval(
|
drawOval(
|
||||||
color = focusLineColor.copy(alpha = 0.5f),
|
color = focusLineColor.copy(alpha = 0.5f),
|
||||||
topLeft = Offset(
|
topLeft = Offset(
|
||||||
|
|
@ -354,14 +425,26 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
|
||||||
style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect)
|
style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw center crosshair
|
// Draw center crosshair (outline + color)
|
||||||
val crosshairSize = 20.dp.toPx()
|
val crosshairSize = 20.dp.toPx()
|
||||||
|
drawLine(
|
||||||
|
color = outlineColor,
|
||||||
|
start = Offset(centerX - crosshairSize, centerY),
|
||||||
|
end = Offset(centerX + crosshairSize, centerY),
|
||||||
|
strokeWidth = 4.dp.toPx()
|
||||||
|
)
|
||||||
drawLine(
|
drawLine(
|
||||||
color = focusLineColor,
|
color = focusLineColor,
|
||||||
start = Offset(centerX - crosshairSize, centerY),
|
start = Offset(centerX - crosshairSize, centerY),
|
||||||
end = Offset(centerX + crosshairSize, centerY),
|
end = Offset(centerX + crosshairSize, centerY),
|
||||||
strokeWidth = 2.dp.toPx()
|
strokeWidth = 2.dp.toPx()
|
||||||
)
|
)
|
||||||
|
drawLine(
|
||||||
|
color = outlineColor,
|
||||||
|
start = Offset(centerX, centerY - crosshairSize),
|
||||||
|
end = Offset(centerX, centerY + crosshairSize),
|
||||||
|
strokeWidth = 4.dp.toPx()
|
||||||
|
)
|
||||||
drawLine(
|
drawLine(
|
||||||
color = focusLineColor,
|
color = focusLineColor,
|
||||||
start = Offset(centerX, centerY - crosshairSize),
|
start = Offset(centerX, centerY - crosshairSize),
|
||||||
|
|
@ -369,7 +452,13 @@ private fun DrawScope.drawRadialOverlay(params: BlurParameters) {
|
||||||
strokeWidth = 2.dp.toPx()
|
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(
|
drawLine(
|
||||||
color = focusLineColor,
|
color = focusLineColor,
|
||||||
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),
|
start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()),
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,15 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import no.naiv.tiltshift.ui.theme.AppColors
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -65,9 +71,9 @@ private fun ZoomButton(
|
||||||
) {
|
) {
|
||||||
val backgroundColor by animateColorAsState(
|
val backgroundColor by animateColorAsState(
|
||||||
targetValue = if (isSelected) {
|
targetValue = if (isSelected) {
|
||||||
Color(0xFFFFB300) // Amber when selected
|
AppColors.Accent
|
||||||
} else {
|
} else {
|
||||||
Color(0x80000000) // Semi-transparent black
|
AppColors.OverlayDark
|
||||||
},
|
},
|
||||||
label = "zoom_button_bg"
|
label = "zoom_button_bg"
|
||||||
)
|
)
|
||||||
|
|
@ -79,10 +85,11 @@ private fun ZoomButton(
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(44.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = onClick),
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.semantics { selected = isSelected; contentDescription = "${preset.label}x zoom" },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -105,8 +112,9 @@ fun ZoomIndicator(
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0x80000000))
|
.background(AppColors.OverlayDark)
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
.semantics { contentDescription = "Current zoom: %.1fx".format(currentZoom) },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
18
app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt
Normal file
18
app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ playServicesLocation = "21.3.0"
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue