/* * Copyright (C) 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.eyedropper.ui import android.graphics.Bitmap import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** * Base Viewmodel with core, shared functions & members such as current bitmap offset and select/ * abort callbacks. Ensures persistence of bitmap offset across configuration change so that the * on-screen reticle stays locked to the same pixel. */ abstract class BaseEyeDropperViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { // "Software" bitmap to retrieve pixels var androidBitmap: Bitmap? = null private set // Bitmap rendered by Composables private val _imageBitmap = MutableStateFlow(null) val imageBitmap: StateFlow = _imageBitmap.asStateFlow() // Source of truth for the center pixel the UI is currently focused on private val _bitmapPointerPosition = MutableStateFlow(Offset.Unspecified) val bitmapPointerPosition: StateFlow = _bitmapPointerPosition.asStateFlow() /** * Return current color at [bitmapPointerPosition] and terminate the respective Controller. */ var sendColorResult: (() -> Unit)? = null protected set /** * Signal cancellation to and terminate the respective Controller. */ var sendAbortResult: (() -> Unit)? = null protected set private val _isInitialized = MutableStateFlow(false) val isInitialized: StateFlow = _isInitialized.asStateFlow() init { val initialX = savedStateHandle.get(SAVED_STATE_POINTER_X) val initialY = savedStateHandle.get(SAVED_STATE_POINTER_Y) if (initialX != null && initialY != null) { _bitmapPointerPosition.value = Offset(initialX, initialY) } } /** * Must be called by controller prior to ViewModel use. * * This sets the bitmap and configures the callbacks to return the result from the bitmap. * May be overridden to initialize additional attributes used by subclasses. */ open fun initialize( bitmap: Bitmap, onColorSelected: (Int) -> Unit, onSelectionCanceled: () -> Unit ) { if (androidBitmap == bitmap && isInitialized.value) return // Already init with this bitmap androidBitmap = bitmap _imageBitmap.value = bitmap.asImageBitmap() sendColorResult = { val currPos = bitmapPointerPosition.value val currBitmap = androidBitmap // Avoid 'impossible smart-cast b/c mutable' if (currPos != Offset.Unspecified && currBitmap != null) { onColorSelected(currBitmap.getPixel( bitmapPointerPosition.value.x.roundToInt(), bitmapPointerPosition.value.y.roundToInt() )) } } sendAbortResult = onSelectionCanceled if (!isInitialized.value) { _isInitialized.value = true } } /** * The setter for [bitmapPointerPosition]. */ open fun updateBitmapPointerPosition(newOffset: Offset) { if (!isInitialized.value) return val newX = newOffset.x val newY = newOffset.y if (bitmapPointerPosition.value.x == newX && bitmapPointerPosition.value.y == newY) return _bitmapPointerPosition.value = clampPosition(newX, newY) savedStateHandle[SAVED_STATE_POINTER_X] = newX savedStateHandle[SAVED_STATE_POINTER_Y] = newY } // Clamp offsets to screen size private fun clampPosition(x: Float, y: Float): Offset { if (!isInitialized.value) return Offset.Unspecified return Offset( x.coerceIn(0f, (androidBitmap!!.width - 1).toFloat()), // Non-null by init check y.coerceIn(0f, (androidBitmap!!.height - 1).toFloat()) ) } private companion object { private const val SAVED_STATE_POINTER_X = "pointerX" private const val SAVED_STATE_POINTER_Y = "pointerY" } }