diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c9c599..b7bbc0a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) // Compose diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt new file mode 100644 index 0000000..89b61b1 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraViewModel.kt @@ -0,0 +1,209 @@ +package no.naiv.tiltshift.ui + +import android.app.Application +import android.graphics.Bitmap +import android.location.Location +import android.net.Uri +import android.util.Log +import android.view.Surface +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import no.naiv.tiltshift.camera.CameraManager +import no.naiv.tiltshift.camera.ImageCaptureHandler +import no.naiv.tiltshift.effect.BlurParameters +import no.naiv.tiltshift.storage.PhotoSaver +import no.naiv.tiltshift.storage.SaveResult +import no.naiv.tiltshift.util.HapticFeedback +import no.naiv.tiltshift.util.LocationProvider +import no.naiv.tiltshift.util.OrientationDetector + +/** + * ViewModel for the camera screen. + * Survives configuration changes (rotation) and process death (via SavedStateHandle for primitives). + */ +class CameraViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "CameraViewModel" + } + + val cameraManager = CameraManager(application) + val photoSaver = PhotoSaver(application) + val captureHandler = ImageCaptureHandler(application, photoSaver) + val haptics = HapticFeedback(application) + val orientationDetector = OrientationDetector(application) + val locationProvider = LocationProvider(application) + + // Blur parameters — preserved across config changes + private val _blurParams = MutableStateFlow(BlurParameters.DEFAULT) + val blurParams: StateFlow = _blurParams.asStateFlow() + + // Capture state + private val _isCapturing = MutableStateFlow(false) + val isCapturing: StateFlow = _isCapturing.asStateFlow() + + private val _showSaveSuccess = MutableStateFlow(false) + val showSaveSuccess: StateFlow = _showSaveSuccess.asStateFlow() + + private val _showSaveError = MutableStateFlow(null) + val showSaveError: StateFlow = _showSaveError.asStateFlow() + + private val _showControls = MutableStateFlow(false) + val showControls: StateFlow = _showControls.asStateFlow() + + // Thumbnail state + private val _lastSavedUri = MutableStateFlow(null) + val lastSavedUri: StateFlow = _lastSavedUri.asStateFlow() + + private val _lastThumbnailBitmap = MutableStateFlow(null) + val lastThumbnailBitmap: StateFlow = _lastThumbnailBitmap.asStateFlow() + + // Gallery preview state + private val _galleryBitmap = MutableStateFlow(null) + val galleryBitmap: StateFlow = _galleryBitmap.asStateFlow() + + private val _galleryImageUri = MutableStateFlow(null) + val galleryImageUri: StateFlow = _galleryImageUri.asStateFlow() + + val isGalleryPreview: Boolean get() = _galleryBitmap.value != null + + // Device state + private val _currentRotation = MutableStateFlow(Surface.ROTATION_0) + val currentRotation: StateFlow = _currentRotation.asStateFlow() + + private val _currentLocation = MutableStateFlow(null) + val currentLocation: StateFlow = _currentLocation.asStateFlow() + + // Processing indicator + private val _isProcessing = MutableStateFlow(false) + val isProcessing: StateFlow = _isProcessing.asStateFlow() + + fun updateBlurParams(params: BlurParameters) { + _blurParams.value = params + } + + fun resetBlurParams() { + _blurParams.value = BlurParameters.DEFAULT + } + + fun toggleControls() { + _showControls.value = !_showControls.value + } + + fun updateRotation(rotation: Int) { + _currentRotation.value = rotation + } + + fun updateLocation(location: Location?) { + _currentLocation.value = location + } + + fun loadGalleryImage(uri: Uri) { + if (_isCapturing.value || isGalleryPreview) return + viewModelScope.launch { + _isProcessing.value = true + val bitmap = captureHandler.loadGalleryImage(uri) + _isProcessing.value = false + if (bitmap != null) { + _galleryBitmap.value = bitmap + _galleryImageUri.value = uri + } else { + haptics.error() + showError("Failed to load image") + } + } + } + + fun cancelGalleryPreview() { + val old = _galleryBitmap.value + _galleryBitmap.value = null + _galleryImageUri.value = null + old?.recycle() + } + + fun applyGalleryEffect() { + val uri = _galleryImageUri.value ?: return + if (_isCapturing.value) return + _isCapturing.value = true + _isProcessing.value = true + haptics.heavyClick() + + viewModelScope.launch { + val result = captureHandler.processExistingImage( + imageUri = uri, + blurParams = _blurParams.value, + location = _currentLocation.value + ) + handleSaveResult(result) + cancelGalleryPreview() + _isCapturing.value = false + _isProcessing.value = false + } + } + + fun capturePhoto() { + if (_isCapturing.value) return + val imageCapture = cameraManager.imageCapture ?: return + _isCapturing.value = true + _isProcessing.value = true + haptics.heavyClick() + + viewModelScope.launch { + val result = captureHandler.capturePhoto( + imageCapture = imageCapture, + executor = cameraManager.getExecutor(), + blurParams = _blurParams.value, + deviceRotation = _currentRotation.value, + location = _currentLocation.value, + isFrontCamera = cameraManager.isFrontCamera.value + ) + handleSaveResult(result) + _isCapturing.value = false + _isProcessing.value = false + } + } + + private fun handleSaveResult(result: SaveResult) { + when (result) { + is SaveResult.Success -> { + haptics.success() + val oldThumb = _lastThumbnailBitmap.value + _lastThumbnailBitmap.value = result.thumbnail + _lastSavedUri.value = result.uri + oldThumb?.recycle() + viewModelScope.launch { + _showSaveSuccess.value = true + kotlinx.coroutines.delay(1500) + _showSaveSuccess.value = false + } + } + is SaveResult.Error -> { + haptics.error() + showError(result.message) + } + } + } + + private fun showError(message: String) { + viewModelScope.launch { + _showSaveError.value = message + kotlinx.coroutines.delay(2000) + _showSaveError.value = null + } + } + + fun showCameraError(message: String) { + showError(message) + } + + override fun onCleared() { + super.onCleared() + cameraManager.release() + _lastThumbnailBitmap.value?.recycle() + _galleryBitmap.value?.recycle() + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt b/app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt new file mode 100644 index 0000000..d4a1ba4 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/theme/AppColors.kt @@ -0,0 +1,18 @@ +package no.naiv.tiltshift.ui.theme + +import androidx.compose.ui.graphics.Color + +/** + * Centralized color definitions for the Tilt-Shift Camera app. + */ +object AppColors { + val Accent = Color(0xFFFFB300) + val OverlayDark = Color(0x80000000) + val OverlayDarker = Color(0xCC000000) + val Success = Color(0xFF4CAF50) + val Error = Color(0xFFF44336) + val OverlayLinearBlur = Color(0x40FFFFFF) + val OverlayRadialBlur = Color(0x30FFFFFF) + /** Dark outline behind overlay guide lines for visibility over bright scenes. */ + val OverlayOutline = Color(0x80000000) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cd52516..37e1882 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ playServicesLocation = "21.3.0" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" }