Add ViewModel for state preservation and centralize color constants

- Create CameraViewModel with AndroidViewModel to survive configuration
  changes (rotation). All blur params, capture state, gallery preview,
  and thumbnail state now live in StateFlow fields
- Create AppColors object to centralize the 12+ hardcoded color literals
  into a single source of truth
- Add lifecycle-viewmodel-compose dependency

Fixes: state lost on rotation, orphaned capture coroutines on config
change, accent color maintenance risk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 12:07:39 +01:00
commit 6a1d66bd4b
4 changed files with 229 additions and 0 deletions

View file

@ -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

View 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()
}
}

View 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)
}

View file

@ -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" }