From a462751b869d0f3034cb7f12e019456f3302b542 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Thu, 14 May 2026 21:38:26 +0000 Subject: [PATCH 01/12] feat: Optimize ElapsedTimeText recompositions and enable tabular figures --- .../feature/preview/PreviewScreen.kt | 25 ++++++++++++++++--- .../capture/CaptureScreenComponents.kt | 11 ++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 90cfbe02a..ad91df3df 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -136,7 +136,8 @@ fun PreviewScreen( ) { Log.d(TAG, "PreviewScreen") - val captureUiState: CaptureUiState by viewModel.captureUiState.collectAsState() + val captureUiStateState = viewModel.captureUiState.collectAsState() + val captureUiState = captureUiStateState.value val debugUiState: DebugUiState by viewModel.debugUiState.collectAsState() val snackBarUiState: SnackBarUiState by viewModel.snackBarUiState.collectAsState() @@ -192,6 +193,19 @@ fun PreviewScreen( ) } + val formattedTimeProvider = remember { + androidx.compose.runtime.derivedStateOf { + val state = (captureUiStateState.value as? CaptureUiState.Ready)?.elapsedTimeUiState + if (state is ElapsedTimeUiState.Enabled) { + state.elapsedTimeNanos.nanoseconds + .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) } + } else { + "" + } + } + } + val formattedTimeProviderLambda = remember(formattedTimeProvider) { { formattedTimeProvider.value } } + val context = LocalContext.current LaunchedEffect(Unit) { debouncedOrientationFlow(context).collect( @@ -275,6 +289,7 @@ fun PreviewScreen( ContentScreen( modifier = modifier, captureUiState = currentUiState, + formattedTimeProvider = formattedTimeProviderLambda, screenFlashUiState = screenFlashUiState, surfaceRequest = surfaceRequest, onNavigateToSettings = onNavigateToSettings, @@ -342,6 +357,7 @@ fun PreviewScreen( @Composable private fun ContentScreen( captureUiState: CaptureUiState.Ready, + formattedTimeProvider: () -> String = { "" }, screenFlashUiState: ScreenFlashUiState, surfaceRequest: SurfaceRequest?, modifier: Modifier = Modifier, @@ -473,15 +489,16 @@ private fun ContentScreen( ) } }, - elapsedTimeDisplay = { + elapsedTimeDisplay = { modifier -> AnimatedVisibility( visible = (captureUiState.videoRecordingState is VideoRecordingState.Active), enter = fadeIn(), exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) ) { + val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) } ElapsedTimeText( - modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiState = captureUiState.elapsedTimeUiState + modifier = elapsedTimeModifier, + formattedTimeProvider = formattedTimeProvider ) } }, diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 59578e45a..9a0929881 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -131,13 +131,14 @@ private const val FOCUS_INDICATOR_RESULT_DELAY = 100L * @param elapsedTimeUiState the [ElapsedTimeUiState] for this component. */ @Composable -fun ElapsedTimeText(modifier: Modifier = Modifier, elapsedTimeUiState: ElapsedTimeUiState) { - if (elapsedTimeUiState is ElapsedTimeUiState.Enabled) { +fun ElapsedTimeText(modifier: Modifier = Modifier, formattedTimeProvider: () -> String) { + val formattedTime = formattedTimeProvider() + if (formattedTime.isNotEmpty()) { Text( modifier = modifier, - text = elapsedTimeUiState.elapsedTimeNanos.nanoseconds - .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) }, - textAlign = TextAlign.Center + text = formattedTime, + textAlign = TextAlign.Center, + style = androidx.compose.ui.text.TextStyle(fontFeatureSettings = "tnum") ) } } From 264b35d60b23b3f06d3b7dc5c342fe9f19803db9 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 00:17:06 +0000 Subject: [PATCH 02/12] docs: add deferred state reading guideline to styleguide --- .gemini/styleguide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index 3a5b03c4a..4c77a52be 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -31,6 +31,7 @@ When reviewing a pull request, focus on the following key areas: * Analyze camera configurations and use cases for potential performance bottlenecks. * Ensure coroutines and asynchronous operations are used efficiently. * **State Conflation in Adapters:** High-frequency stream data (e.g., nanosecond timestamps) should be rounded or conflated at the `UiStateAdapter` level before reaching the UI state, to avoid unnecessary recompositions. [Introduced in PR #514] + * **Deferred State Reading with Lambdas:** Pass lambda providers `() -> T` instead of raw values `T` to child composables when dealing with high-frequency updates (e.g., timers), to isolate recompositions to the child component. [Introduced in PR #515] 4. **Jetpack Compose & CameraX Usage** * Verify that Compose and CameraX APIs are used correctly and effectively. From 76fcf8da12760daa73b599a9e62135f98bab4f79 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 00:25:23 +0000 Subject: [PATCH 03/12] refactor: apply review comments to update KDoc and preserve text style --- .../ui/components/capture/CaptureScreenComponents.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 9a0929881..9b1646b94 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -128,7 +128,7 @@ private const val FOCUS_INDICATOR_RESULT_DELAY = 100L * A composable that displays the elapsed time of a video recording in a "MM:SS" format. * This text is only visible during an active recording. * - * @param elapsedTimeUiState the [ElapsedTimeUiState] for this component. + * @param formattedTimeProvider a provider for the formatted time string. */ @Composable fun ElapsedTimeText(modifier: Modifier = Modifier, formattedTimeProvider: () -> String) { @@ -138,7 +138,7 @@ fun ElapsedTimeText(modifier: Modifier = Modifier, formattedTimeProvider: () -> modifier = modifier, text = formattedTime, textAlign = TextAlign.Center, - style = androidx.compose.ui.text.TextStyle(fontFeatureSettings = "tnum") + style = androidx.compose.material3.LocalTextStyle.current.copy(fontFeatureSettings = "tnum") ) } } From 62730a1e41a1f4a64152d4dce64784ec0a0cecd4 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 03:09:59 +0000 Subject: [PATCH 04/12] feat: Optimize ElapsedTimeText recompositions --- .../feature/preview/PreviewScreen.kt | 154 ++++++++---------- 1 file changed, 70 insertions(+), 84 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index ad91df3df..ee692db8c 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,6 +59,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tracing.Trace +import kotlin.time.Duration.Companion.nanoseconds import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted @@ -113,6 +115,7 @@ import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState import com.google.jetpackcamera.ui.uistate.capture.ScreenFlashUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomControlUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomUiState +import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import kotlinx.coroutines.flow.transformWhile @@ -137,12 +140,11 @@ fun PreviewScreen( Log.d(TAG, "PreviewScreen") val captureUiStateState = viewModel.captureUiState.collectAsState() - val captureUiState = captureUiStateState.value val debugUiState: DebugUiState by viewModel.debugUiState.collectAsState() val snackBarUiState: SnackBarUiState by viewModel.snackBarUiState.collectAsState() - val screenFlashUiState = - (captureUiState as? CaptureUiState.Ready)?.screenFlashUiState ?: ScreenFlashUiState() + val captureUiStateProvider = remember { { captureUiStateState.value as CaptureUiState.Ready } } + val isReady by remember { derivedStateOf { captureUiStateState.value is CaptureUiState.Ready } } val surfaceRequest: SurfaceRequest? by viewModel.surfaceRequest.collectAsState() @@ -168,7 +170,7 @@ fun PreviewScreen( if (Trace.isEnabled()) { LaunchedEffect(onFirstFrameCaptureCompleted) { - snapshotFlow { captureUiState } + snapshotFlow { captureUiStateState.value } .transformWhile { var continueCollecting = true (it as? CaptureUiState.Ready)?.let { ready -> @@ -184,27 +186,15 @@ fun PreviewScreen( } } - when (val currentUiState = captureUiState) { - is CaptureUiState.NotReady -> LoadingScreen() - is CaptureUiState.Ready -> { - var initialRecordingSettings by remember { - mutableStateOf( - null - ) - } - - val formattedTimeProvider = remember { - androidx.compose.runtime.derivedStateOf { - val state = (captureUiStateState.value as? CaptureUiState.Ready)?.elapsedTimeUiState - if (state is ElapsedTimeUiState.Enabled) { - state.elapsedTimeNanos.nanoseconds - .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) } - } else { - "" - } - } - } - val formattedTimeProviderLambda = remember(formattedTimeProvider) { { formattedTimeProvider.value } } + if (!isReady) { + LoadingScreen() + } else { + val currentUiState = captureUiStateState.value as CaptureUiState.Ready + var initialRecordingSettings by remember { + mutableStateOf( + null + ) + } val context = LocalContext.current LaunchedEffect(Unit) { @@ -288,9 +278,7 @@ fun PreviewScreen( ContentScreen( modifier = modifier, - captureUiState = currentUiState, - formattedTimeProvider = formattedTimeProviderLambda, - screenFlashUiState = screenFlashUiState, + captureUiStateProvider = captureUiStateProvider, surfaceRequest = surfaceRequest, onNavigateToSettings = onNavigateToSettings, @@ -350,15 +338,12 @@ fun PreviewScreen( } } } - } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ContentScreen( - captureUiState: CaptureUiState.Ready, - formattedTimeProvider: () -> String = { "" }, - screenFlashUiState: ScreenFlashUiState, + captureUiStateProvider: () -> CaptureUiState.Ready, surfaceRequest: SurfaceRequest?, modifier: Modifier = Modifier, onNavigateToSettings: () -> Unit = {}, @@ -379,18 +364,16 @@ private fun ContentScreen( screenFlashController: ScreenFlashController? = null ) { val onFlipCamera = { - if (captureUiState.flipLensUiState is FlipLensUiState.Available) { + val readyState = captureUiStateProvider() + if (readyState.flipLensUiState is FlipLensUiState.Available) { quickSettingsController?.setLensFacing( - ( - captureUiState.flipLensUiState as FlipLensUiState.Available - ) - .selectedLensFacing.flip() + (readyState.flipLensUiState as FlipLensUiState.Available).selectedLensFacing.flip() ) } } - val isAudioEnabled = remember(captureUiState) { - captureUiState.audioUiState is AudioUiState.Enabled.On + val isAudioEnabled by remember { + derivedStateOf { captureUiStateProvider().audioUiState is AudioUiState.Enabled.On } } val onToggleAudio: () -> Unit = remember(isAudioEnabled) { { @@ -400,52 +383,49 @@ private fun ContentScreen( LayoutWrapper( modifier = modifier, - hdrIndicator = { HdrIndicator(modifier = it, hdrUiState = captureUiState.hdrUiState) }, + hdrIndicator = { HdrIndicator(modifier = it, hdrUiState = captureUiStateProvider().hdrUiState) }, flashModeIndicator = { FlashModeIndicator( modifier = it, - flashModeUiState = captureUiState.flashModeUiState + flashModeUiState = captureUiStateProvider().flashModeUiState ) }, videoQualityIndicator = { VideoQualityIcon( - captureUiState.videoQuality, + captureUiStateProvider().videoQuality, Modifier.testTag(VIDEO_QUALITY_TAG) ) }, stabilizationIndicator = { StabilizationIcon( modifier = it, - stabilizationUiState = captureUiState.stabilizationUiState + stabilizationUiState = captureUiStateProvider().stabilizationUiState ) }, viewfinder = { PreviewDisplay( - previewDisplayUiState = captureUiState.previewDisplayUiState, + previewDisplayUiState = captureUiStateProvider().previewDisplayUiState, onFlipCamera = onFlipCamera, onTapToFocus = cameraController?.let { it::tapToFocus } ?: { _, _ -> }, onScaleZoom = { onScaleZoom(it, LensToZoom.PRIMARY) }, surfaceRequest = surfaceRequest, onRequestWindowColorMode = onRequestWindowColorMode, - focusMeteringUiState = captureUiState.focusMeteringUiState + focusMeteringUiState = captureUiStateProvider().focusMeteringUiState ) }, captureButton = { + val quickSettingsUiState = captureUiStateProvider().quickSettingsUiState fun runCaptureAction(action: () -> Unit) { - if ((captureUiState.quickSettingsUiState as? QuickSettingsUiState.Available) - ?.quickSettingsIsOpen == true - ) { + if ((quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true) { quickSettingsController?.toggleQuickSettings() } action() } CaptureButton( - captureButtonUiState = captureUiState.captureButtonUiState, - isQuickSettingsOpen = ( - captureUiState.quickSettingsUiState as? - QuickSettingsUiState.Available - )?.quickSettingsIsOpen ?: false, + captureButtonUiState = captureUiStateProvider().captureButtonUiState, + isQuickSettingsOpen = (quickSettingsUiState as? QuickSettingsUiState.Available) + ?.quickSettingsIsOpen ?: false, onCaptureImage = { runCaptureAction { captureController?.captureImage(it) @@ -471,9 +451,9 @@ private fun ContentScreen( FlipCameraButton( modifier = Modifier.testTag(FLIP_CAMERA_BUTTON), onClick = onFlipCamera, - flipLensUiState = captureUiState.flipLensUiState, + flipLensUiState = captureUiStateProvider().flipLensUiState, // enable only when phone has front and rear camera - enabledCondition = when (val flipLensUiState = captureUiState.flipLensUiState) { + enabledCondition = when (val flipLensUiState = captureUiStateProvider().flipLensUiState) { is FlipLensUiState.Available -> flipLensUiState.availableLensFacings.size > 1 FlipLensUiState.Unavailable -> false } @@ -482,7 +462,7 @@ private fun ContentScreen( zoomLevelDisplay = { Column(modifier = Modifier, horizontalAlignment = Alignment.CenterHorizontally) { ZoomButtonRow( - zoomControlUiState = captureUiState.zoomControlUiState, + zoomControlUiState = captureUiStateProvider().zoomControlUiState, onChangeZoom = { targetZoom -> onAnimateZoom(targetZoom, LensToZoom.PRIMARY) } @@ -491,20 +471,28 @@ private fun ContentScreen( }, elapsedTimeDisplay = { modifier -> AnimatedVisibility( - visible = (captureUiState.videoRecordingState is VideoRecordingState.Active), + visible = (captureUiStateProvider().videoRecordingState is VideoRecordingState.Active), enter = fadeIn(), exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) ) { val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) } ElapsedTimeText( modifier = elapsedTimeModifier, - formattedTimeProvider = formattedTimeProvider + formattedTimeProvider = { + val state = captureUiStateProvider().elapsedTimeUiState + if (state is ElapsedTimeUiState.Enabled) { + state.elapsedTimeNanos.nanoseconds + .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) } + } else { + "" + } + } ) } }, quickSettingsButton = { AnimatedVisibility( - visible = (captureUiState.videoRecordingState !is VideoRecordingState.Active), + visible = (captureUiStateProvider().videoRecordingState !is VideoRecordingState.Active), enter = fadeIn(), exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) ) { @@ -512,7 +500,7 @@ private fun ContentScreen( ToggleQuickSettingsButton( modifier = it, isOpen = ( - captureUiState.quickSettingsUiState + captureUiStateProvider().quickSettingsUiState as? QuickSettingsUiState.Available )?.quickSettingsIsOpen == true, quickSettingsController = quickSettingsController @@ -525,13 +513,13 @@ private fun ContentScreen( AmplitudeToggleButton( modifier = it, onToggleAudio = onToggleAudio, - audioUiState = captureUiState.audioUiState + audioUiState = captureUiStateProvider().audioUiState ) }, captureModeToggle = { - if (captureUiState.captureModeToggleUiState is CaptureModeToggleUiState.Available) { + if (captureUiStateProvider().captureModeToggleUiState is CaptureModeToggleUiState.Available) { CaptureModeToggleButton( - uiState = captureUiState.captureModeToggleUiState + uiState = captureUiStateProvider().captureModeToggleUiState as CaptureModeToggleUiState.Available, quickSettingsController = quickSettingsController, @@ -544,7 +532,7 @@ private fun ContentScreen( quickSettingsController?.let { quickSettingsController -> QuickSettingsBottomSheet( modifier = it, - quickSettingsUiState = captureUiState.quickSettingsUiState, + quickSettingsUiState = captureUiStateProvider().quickSettingsUiState, onNavigateToSettings = { quickSettingsController.toggleQuickSettings() onNavigateToSettings() @@ -579,7 +567,7 @@ private fun ContentScreen( // may need to be handled later. Compose smart recomposition should be able to optimize this // if the relevant states are no longer changing. ScreenFlashScreen( - screenFlashUiState = screenFlashUiState, + screenFlashUiState = captureUiStateProvider().screenFlashUiState, onInitialBrightnessCalculated = { screenFlashController?.setClearUiScreenBrightness( it @@ -605,12 +593,12 @@ private fun ContentScreen( pauseToggleButton = { PauseResumeToggleButton( onSetPause = captureController?.let { it::setPaused } ?: { _ -> }, - currentRecordingState = captureUiState.videoRecordingState + currentRecordingState = captureUiStateProvider().videoRecordingState ) }, imageWell = { modifier -> - if (captureUiState.externalCaptureMode == ExternalCaptureMode.Standard) { - (captureUiState.imageWellUiState as? ImageWellUiState.Content)?.let { + if (captureUiStateProvider().externalCaptureMode == ExternalCaptureMode.Standard) { + (captureUiStateProvider().imageWellUiState as? ImageWellUiState.Content)?.let { ImageWell( modifier = modifier, imageWellUiState = it, @@ -709,8 +697,7 @@ private fun LayoutWrapper( private fun ContentScreenPreview() { MaterialTheme { ContentScreen( - captureUiState = FAKE_PREVIEW_UI_STATE_READY, - screenFlashUiState = ScreenFlashUiState(), + captureUiStateProvider = { FAKE_PREVIEW_UI_STATE_READY }, surfaceRequest = null ) } @@ -721,8 +708,7 @@ private fun ContentScreenPreview() { private fun ContentScreen_Standard_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy(), - screenFlashUiState = ScreenFlashUiState(), + captureUiStateProvider = { FAKE_PREVIEW_UI_STATE_READY.copy() }, surfaceRequest = null ) } @@ -733,10 +719,11 @@ private fun ContentScreen_Standard_Idle() { private fun ContentScreen_ImageOnly_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) - ), - screenFlashUiState = ScreenFlashUiState(), + captureUiStateProvider = { + FAKE_PREVIEW_UI_STATE_READY.copy( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + ) + }, surfaceRequest = null ) } @@ -747,10 +734,11 @@ private fun ContentScreen_ImageOnly_Idle() { private fun ContentScreen_VideoOnly_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY) - ), - screenFlashUiState = ScreenFlashUiState(), + captureUiStateProvider = { + FAKE_PREVIEW_UI_STATE_READY.copy( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY) + ) + }, surfaceRequest = null ) } @@ -761,8 +749,7 @@ private fun ContentScreen_VideoOnly_Idle() { private fun ContentScreen_Standard_Recording() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiState = FAKE_PREVIEW_UI_STATE_PRESSED_RECORDING, - screenFlashUiState = ScreenFlashUiState(), + captureUiStateProvider = { FAKE_PREVIEW_UI_STATE_PRESSED_RECORDING }, surfaceRequest = null ) } @@ -773,8 +760,7 @@ private fun ContentScreen_Standard_Recording() { private fun ContentScreen_Locked_Recording() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiState = FAKE_PREVIEW_UI_STATE_LOCKED_RECORDING, - screenFlashUiState = ScreenFlashUiState(), + captureUiStateProvider = { FAKE_PREVIEW_UI_STATE_LOCKED_RECORDING }, surfaceRequest = null ) } From 292f588347c8f2740cf7be5832bf4d6cb6fa4428 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 18:26:11 +0000 Subject: [PATCH 05/12] feat: Address review comments for lambdas and ElapsedTimeText --- .../feature/preview/PreviewScreen.kt | 274 +++++++++++------- 1 file changed, 166 insertions(+), 108 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index ee692db8c..07281161a 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -381,41 +381,98 @@ private fun ContentScreen( } } - LayoutWrapper( - modifier = modifier, - hdrIndicator = { HdrIndicator(modifier = it, hdrUiState = captureUiStateProvider().hdrUiState) }, - flashModeIndicator = { + // Remember content slot lambdas to avoid unnecessary recompositions + val hdrIndicatorLambda = remember { + @Composable { modifier: Modifier -> HdrIndicator(modifier = modifier, hdrUiState = captureUiStateProvider().hdrUiState) } + } + val flashModeIndicatorLambda = remember { + @Composable { modifier: Modifier -> FlashModeIndicator( - modifier = it, + modifier = modifier, flashModeUiState = captureUiStateProvider().flashModeUiState ) - }, - videoQualityIndicator = { + } + } + val videoQualityIndicatorLambda = remember { + @Composable { modifier: Modifier -> VideoQualityIcon( captureUiStateProvider().videoQuality, - Modifier.testTag(VIDEO_QUALITY_TAG) + modifier.testTag(VIDEO_QUALITY_TAG) ) - }, - stabilizationIndicator = { + } + } + val stabilizationIndicatorLambda = remember { + @Composable { modifier: Modifier -> StabilizationIcon( - modifier = it, + modifier = modifier, stabilizationUiState = captureUiStateProvider().stabilizationUiState ) - }, + } + } - viewfinder = { + val onTapToFocusLambda = cameraController?.let { it::tapToFocus } ?: remember { { _: Float, _: Float -> } } + val currentOnScaleZoom = rememberUpdatedState(onScaleZoom) + val onScaleZoomLambda = remember { { zoomRatio: Float -> currentOnScaleZoom.value(zoomRatio, LensToZoom.PRIMARY) } } + + val viewfinderLambda = remember(onFlipCamera, onTapToFocusLambda, onScaleZoomLambda, surfaceRequest, onRequestWindowColorMode) { + @Composable { modifier: Modifier -> PreviewDisplay( previewDisplayUiState = captureUiStateProvider().previewDisplayUiState, onFlipCamera = onFlipCamera, - onTapToFocus = cameraController?.let { it::tapToFocus } ?: { _, _ -> }, - onScaleZoom = { onScaleZoom(it, LensToZoom.PRIMARY) }, + onTapToFocus = onTapToFocusLambda, + onScaleZoom = onScaleZoomLambda, surfaceRequest = surfaceRequest, onRequestWindowColorMode = onRequestWindowColorMode, focusMeteringUiState = captureUiStateProvider().focusMeteringUiState ) - }, - captureButton = { - val quickSettingsUiState = captureUiStateProvider().quickSettingsUiState + } + } + + val flipCameraButtonLambda = remember(onFlipCamera) { + @Composable { modifier: Modifier -> + FlipCameraButton( + modifier = modifier.testTag(FLIP_CAMERA_BUTTON), + onClick = onFlipCamera, + flipLensUiState = captureUiStateProvider().flipLensUiState, + enabledCondition = when (val flipLensUiState = captureUiStateProvider().flipLensUiState) { + is FlipLensUiState.Available -> flipLensUiState.availableLensFacings.size > 1 + FlipLensUiState.Unavailable -> false + } + ) + } + } + + val onChangeZoomLambda = remember(onAnimateZoom) { { targetZoom: Float -> onAnimateZoom(targetZoom, LensToZoom.PRIMARY) } } + + val zoomLevelDisplayLambda = remember(onChangeZoomLambda) { + @Composable { modifier: Modifier -> + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + ZoomButtonRow( + zoomControlUiState = captureUiStateProvider().zoomControlUiState, + onChangeZoom = onChangeZoomLambda + ) + } + } + } + + val elapsedTimeDisplayLambda = remember { + @Composable { modifier: Modifier -> + AnimatedVisibility( + visible = (captureUiStateProvider().videoRecordingState is VideoRecordingState.Active), + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) } + ElapsedTimeText( + modifier = elapsedTimeModifier, + elapsedTimeUiState = captureUiStateProvider().elapsedTimeUiState + ) + } + } + } + + val captureButtonLambda = remember(onIncrementZoom, quickSettingsController, captureController) { + @Composable { modifier: Modifier -> fun runCaptureAction(action: () -> Unit) { if ((quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true) { quickSettingsController?.toggleQuickSettings() @@ -447,50 +504,8 @@ private fun ContentScreen( } ) }, - flipCameraButton = { - FlipCameraButton( - modifier = Modifier.testTag(FLIP_CAMERA_BUTTON), - onClick = onFlipCamera, - flipLensUiState = captureUiStateProvider().flipLensUiState, - // enable only when phone has front and rear camera - enabledCondition = when (val flipLensUiState = captureUiStateProvider().flipLensUiState) { - is FlipLensUiState.Available -> flipLensUiState.availableLensFacings.size > 1 - FlipLensUiState.Unavailable -> false - } - ) - }, - zoomLevelDisplay = { - Column(modifier = Modifier, horizontalAlignment = Alignment.CenterHorizontally) { - ZoomButtonRow( - zoomControlUiState = captureUiStateProvider().zoomControlUiState, - onChangeZoom = { targetZoom -> - onAnimateZoom(targetZoom, LensToZoom.PRIMARY) - } - ) - } - }, - elapsedTimeDisplay = { modifier -> - AnimatedVisibility( - visible = (captureUiStateProvider().videoRecordingState is VideoRecordingState.Active), - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) } - ElapsedTimeText( - modifier = elapsedTimeModifier, - formattedTimeProvider = { - val state = captureUiStateProvider().elapsedTimeUiState - if (state is ElapsedTimeUiState.Enabled) { - state.elapsedTimeNanos.nanoseconds - .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) } - } else { - "" - } - } - ) - } - }, - quickSettingsButton = { + val quickSettingsButtonLambda = remember(quickSettingsController) { + @Composable { modifier: Modifier -> AnimatedVisibility( visible = (captureUiStateProvider().videoRecordingState !is VideoRecordingState.Active), enter = fadeIn(), @@ -498,40 +513,44 @@ private fun ContentScreen( ) { quickSettingsController?.let { quickSettingsController -> ToggleQuickSettingsButton( - modifier = it, - isOpen = ( - captureUiStateProvider().quickSettingsUiState - as? QuickSettingsUiState.Available - )?.quickSettingsIsOpen == true, + modifier = modifier, + isOpen = (captureUiStateProvider().quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true, quickSettingsController = quickSettingsController - ) } } - }, - audioToggleButton = { + } + } + + val audioToggleButtonLambda = remember(onToggleAudio) { + @Composable { modifier: Modifier -> AmplitudeToggleButton( - modifier = it, + modifier = modifier, onToggleAudio = onToggleAudio, audioUiState = captureUiStateProvider().audioUiState ) - }, - captureModeToggle = { - if (captureUiStateProvider().captureModeToggleUiState is CaptureModeToggleUiState.Available) { - CaptureModeToggleButton( - uiState = captureUiStateProvider().captureModeToggleUiState - as CaptureModeToggleUiState.Available, + } + } + val captureModeToggleLambda = remember(quickSettingsController, snackBarController) { + @Composable { modifier: Modifier -> + val captureModeToggleUiState = captureUiStateProvider().captureModeToggleUiState + if (captureModeToggleUiState is CaptureModeToggleUiState.Available) { + CaptureModeToggleButton( + uiState = captureModeToggleUiState, quickSettingsController = quickSettingsController, snackBarController = snackBarController, - modifier = it.testTag(CAPTURE_MODE_TOGGLE_BUTTON) + modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON) ) } - }, - quickSettingsOverlay = { + } + } + + val quickSettingsOverlayLambda = remember(quickSettingsController, onNavigateToSettings) { + @Composable { modifier: Modifier -> quickSettingsController?.let { quickSettingsController -> QuickSettingsBottomSheet( - modifier = it, + modifier = modifier, quickSettingsUiState = captureUiStateProvider().quickSettingsUiState, onNavigateToSettings = { quickSettingsController.toggleQuickSettings() @@ -540,9 +559,13 @@ private fun ContentScreen( quickSettingsController = quickSettingsController ) } - }, - debugOverlay = { modifier, extraControls -> - (debugUiState as? DebugUiState.Enabled)?.let { debugUiState -> + Unit + } + } + + val debugOverlayLambda = remember(debugController, onAbsoluteZoom, debugUiState) { + @Composable { modifier: Modifier, extraControls: Array<@Composable () -> Unit>? -> + if (debugUiState is DebugUiState.Enabled) { debugController?.let { debugController -> DebugOverlay( modifier = modifier, @@ -553,29 +576,33 @@ private fun ContentScreen( ) } } - }, - debugVisibilityWrapper = { content -> - val uiState = debugUiState - if (uiState !is DebugUiState.Enabled || !uiState.debugHidingComponents) { + Unit + } + } + + val debugVisibilityWrapperLambda = remember(debugUiState) { + @Composable { content: @Composable () -> Unit -> + if (debugUiState !is DebugUiState.Enabled || !debugUiState.debugHidingComponents) { content() } - }, - screenFlashOverlay = { - // Screen flash overlay that stays on top of everything but invisible normally. This should - // not be enabled based on whether screen flash is enabled because a previous image capture - // may still be running after flash mode change and clear actions (e.g. brightness restore) - // may need to be handled later. Compose smart recomposition should be able to optimize this - // if the relevant states are no longer changing. + Unit + } + } + + val screenFlashOverlayLambda = remember(screenFlashController) { + @Composable { modifier: Modifier -> ScreenFlashScreen( screenFlashUiState = captureUiStateProvider().screenFlashUiState, onInitialBrightnessCalculated = { - screenFlashController?.setClearUiScreenBrightness( - it - ) + screenFlashController?.setClearUiScreenBrightness(it) } ) - }, - snackBar = { modifier, snackbarHostState -> + } + } + + val snackBarLambda = remember(snackBarController) { + @Composable { modifier: Modifier, snackbarHostState: SnackbarHostState -> + val snackBarUiState = snackBarUiState if (snackBarUiState is SnackBarUiState.Enabled) { val snackBarData = snackBarUiState.snackBarQueue.peek() if (snackBarData != null) { @@ -589,27 +616,58 @@ private fun ContentScreen( } } } - }, - pauseToggleButton = { + } + } + + val pauseToggleButtonLambda = remember(captureController) { + @Composable { modifier: Modifier -> PauseResumeToggleButton( + modifier = modifier, onSetPause = captureController?.let { it::setPaused } ?: { _ -> }, currentRecordingState = captureUiStateProvider().videoRecordingState ) - }, - imageWell = { modifier -> - if (captureUiStateProvider().externalCaptureMode == ExternalCaptureMode.Standard) { - (captureUiStateProvider().imageWellUiState as? ImageWellUiState.Content)?.let { + } + } + + val imageWellLambda = remember(imageWellController, onNavigatePostCapture) { + @Composable { modifier: Modifier -> + val readyState = captureUiStateProvider() + if (readyState.externalCaptureMode == ExternalCaptureMode.Standard) { + (readyState.imageWellUiState as? ImageWellUiState.Content)?.let { contentState -> ImageWell( modifier = modifier, - imageWellUiState = it, + imageWellUiState = contentState, onClick = { - imageWellController?.imageWellToRepository(it.mediaDescriptor) + imageWellController?.imageWellToRepository(contentState.mediaDescriptor) onNavigatePostCapture() } ) } } } + } + + LayoutWrapper( + modifier = modifier, + hdrIndicator = hdrIndicatorLambda, + flashModeIndicator = flashModeIndicatorLambda, + videoQualityIndicator = videoQualityIndicatorLambda, + stabilizationIndicator = stabilizationIndicatorLambda, + viewfinder = viewfinderLambda, + captureButton = captureButtonLambda, + flipCameraButton = flipCameraButtonLambda, + zoomLevelDisplay = zoomLevelDisplayLambda, + elapsedTimeDisplay = elapsedTimeDisplayLambda, + quickSettingsButton = quickSettingsButtonLambda, + audioToggleButton = audioToggleButtonLambda, + captureModeToggle = captureModeToggleLambda, + quickSettingsOverlay = quickSettingsOverlayLambda, + debugOverlay = debugOverlayLambda, + debugVisibilityWrapper = debugVisibilityWrapperLambda, + screenFlashOverlay = screenFlashOverlayLambda, + snackBar = snackBarLambda, + pauseToggleButton = pauseToggleButtonLambda, + imageWell = imageWellLambda ) } From 54b093bff2b067054fedfa462e0c4b285f120085 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 17:28:01 +0000 Subject: [PATCH 06/12] Remove lambda provider for ElapsedTimeText and revert to passing ElapsedTimeUiState directly. --- .../ui/components/capture/CaptureScreenComponents.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 9b1646b94..41475c111 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -131,8 +131,14 @@ private const val FOCUS_INDICATOR_RESULT_DELAY = 100L * @param formattedTimeProvider a provider for the formatted time string. */ @Composable -fun ElapsedTimeText(modifier: Modifier = Modifier, formattedTimeProvider: () -> String) { - val formattedTime = formattedTimeProvider() +fun ElapsedTimeText(modifier: Modifier = Modifier, elapsedTimeUiState: ElapsedTimeUiState) { + val formattedTime = when (elapsedTimeUiState) { + is ElapsedTimeUiState.Enabled -> { + elapsedTimeUiState.elapsedTimeNanos.nanoseconds + .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) } + } + ElapsedTimeUiState.Unavailable -> "" + } if (formattedTime.isNotEmpty()) { Text( modifier = modifier, From c182ec813e3584253ad79a0928cd2e46b667f5f0 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 17:30:55 +0000 Subject: [PATCH 07/12] Use safe cast in captureUiStateProvider to avoid potential crashes. --- .../com/google/jetpackcamera/feature/preview/PreviewScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 07281161a..f52483ba9 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -143,7 +143,7 @@ fun PreviewScreen( val debugUiState: DebugUiState by viewModel.debugUiState.collectAsState() val snackBarUiState: SnackBarUiState by viewModel.snackBarUiState.collectAsState() - val captureUiStateProvider = remember { { captureUiStateState.value as CaptureUiState.Ready } } + val captureUiStateProvider = remember { { captureUiStateState.value as? CaptureUiState.Ready ?: CaptureUiState.Ready() } } val isReady by remember { derivedStateOf { captureUiStateState.value is CaptureUiState.Ready } } val surfaceRequest: SurfaceRequest? From 98762c5cbb63b117d93a85253613170aaf6e69f4 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 18:15:29 +0000 Subject: [PATCH 08/12] Couple isReady and provider in PreviewScreen to make it safe --- .../feature/preview/PreviewScreen.kt | 337 ++++++++++-------- 1 file changed, 193 insertions(+), 144 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index f52483ba9..724e2b144 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -143,7 +143,7 @@ fun PreviewScreen( val debugUiState: DebugUiState by viewModel.debugUiState.collectAsState() val snackBarUiState: SnackBarUiState by viewModel.snackBarUiState.collectAsState() - val captureUiStateProvider = remember { { captureUiStateState.value as? CaptureUiState.Ready ?: CaptureUiState.Ready() } } + val captureUiStateProvider = remember { { captureUiStateState.value as? CaptureUiState.Ready } } val isReady by remember { derivedStateOf { captureUiStateState.value is CaptureUiState.Ready } } val surfaceRequest: SurfaceRequest? @@ -343,7 +343,7 @@ fun PreviewScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ContentScreen( - captureUiStateProvider: () -> CaptureUiState.Ready, + captureUiStateProvider: () -> CaptureUiState.Ready?, surfaceRequest: SurfaceRequest?, modifier: Modifier = Modifier, onNavigateToSettings: () -> Unit = {}, @@ -363,17 +363,19 @@ private fun ContentScreen( cameraController: CameraController? = null, screenFlashController: ScreenFlashController? = null ) { - val onFlipCamera = { - val readyState = captureUiStateProvider() - if (readyState.flipLensUiState is FlipLensUiState.Available) { - quickSettingsController?.setLensFacing( - (readyState.flipLensUiState as FlipLensUiState.Available).selectedLensFacing.flip() - ) + val onFlipCamera = remember { + { + val readyState = captureUiStateProvider() + if (readyState?.flipLensUiState is FlipLensUiState.Available) { + quickSettingsController?.setLensFacing( + (readyState.flipLensUiState as FlipLensUiState.Available).selectedLensFacing.flip() + ) + } } } val isAudioEnabled by remember { - derivedStateOf { captureUiStateProvider().audioUiState is AudioUiState.Enabled.On } + derivedStateOf { captureUiStateProvider()?.audioUiState is AudioUiState.Enabled.On } } val onToggleAudio: () -> Unit = remember(isAudioEnabled) { { @@ -383,30 +385,44 @@ private fun ContentScreen( // Remember content slot lambdas to avoid unnecessary recompositions val hdrIndicatorLambda = remember { - @Composable { modifier: Modifier -> HdrIndicator(modifier = modifier, hdrUiState = captureUiStateProvider().hdrUiState) } + @Composable { modifier: Modifier -> + val readyState = captureUiStateProvider() + if (readyState != null) { + HdrIndicator(modifier = modifier, hdrUiState = readyState.hdrUiState) + } + } } val flashModeIndicatorLambda = remember { @Composable { modifier: Modifier -> - FlashModeIndicator( - modifier = modifier, - flashModeUiState = captureUiStateProvider().flashModeUiState - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + FlashModeIndicator( + modifier = modifier, + flashModeUiState = readyState.flashModeUiState + ) + } } } val videoQualityIndicatorLambda = remember { @Composable { modifier: Modifier -> - VideoQualityIcon( - captureUiStateProvider().videoQuality, - modifier.testTag(VIDEO_QUALITY_TAG) - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + VideoQualityIcon( + readyState.videoQuality, + modifier.testTag(VIDEO_QUALITY_TAG) + ) + } } } val stabilizationIndicatorLambda = remember { @Composable { modifier: Modifier -> - StabilizationIcon( - modifier = modifier, - stabilizationUiState = captureUiStateProvider().stabilizationUiState - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + StabilizationIcon( + modifier = modifier, + stabilizationUiState = readyState.stabilizationUiState + ) + } } } @@ -416,107 +432,123 @@ private fun ContentScreen( val viewfinderLambda = remember(onFlipCamera, onTapToFocusLambda, onScaleZoomLambda, surfaceRequest, onRequestWindowColorMode) { @Composable { modifier: Modifier -> - PreviewDisplay( - previewDisplayUiState = captureUiStateProvider().previewDisplayUiState, - onFlipCamera = onFlipCamera, - onTapToFocus = onTapToFocusLambda, - onScaleZoom = onScaleZoomLambda, - surfaceRequest = surfaceRequest, - onRequestWindowColorMode = onRequestWindowColorMode, - focusMeteringUiState = captureUiStateProvider().focusMeteringUiState - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + PreviewDisplay( + previewDisplayUiState = readyState.previewDisplayUiState, + onFlipCamera = onFlipCamera, + onTapToFocus = onTapToFocusLambda, + onScaleZoom = onScaleZoomLambda, + surfaceRequest = surfaceRequest, + onRequestWindowColorMode = onRequestWindowColorMode, + focusMeteringUiState = readyState.focusMeteringUiState + ) + } } } - val flipCameraButtonLambda = remember(onFlipCamera) { + val captureButtonLambda = remember(onIncrementZoom, quickSettingsController, captureController) { @Composable { modifier: Modifier -> - FlipCameraButton( - modifier = modifier.testTag(FLIP_CAMERA_BUTTON), - onClick = onFlipCamera, - flipLensUiState = captureUiStateProvider().flipLensUiState, - enabledCondition = when (val flipLensUiState = captureUiStateProvider().flipLensUiState) { - is FlipLensUiState.Available -> flipLensUiState.availableLensFacings.size > 1 - FlipLensUiState.Unavailable -> false + val readyState = captureUiStateProvider() + if (readyState != null) { + val quickSettingsUiState = readyState.quickSettingsUiState + fun runCaptureAction(action: () -> Unit) { + if ((quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true) { + quickSettingsController?.toggleQuickSettings() + } + action() } - ) + CaptureButton( + captureButtonUiState = readyState.captureButtonUiState, + isQuickSettingsOpen = (quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen ?: false, + onCaptureImage = { + runCaptureAction { + captureController?.captureImage(it) + } + }, + onIncrementZoom = { targetZoom -> + onIncrementZoom(targetZoom, LensToZoom.PRIMARY) + }, + onStartVideoRecording = { + runCaptureAction { + captureController?.startVideoRecording() + } + }, + onStopVideoRecording = { captureController?.stopVideoRecording() }, + onLockVideoRecording = { isLocked -> + captureController?.setLockedRecording(isLocked) + } + ) + } } } - val onChangeZoomLambda = remember(onAnimateZoom) { { targetZoom: Float -> onAnimateZoom(targetZoom, LensToZoom.PRIMARY) } } - - val zoomLevelDisplayLambda = remember(onChangeZoomLambda) { + val flipCameraButtonLambda = remember(onFlipCamera) { @Composable { modifier: Modifier -> - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - ZoomButtonRow( - zoomControlUiState = captureUiStateProvider().zoomControlUiState, - onChangeZoom = onChangeZoomLambda + val readyState = captureUiStateProvider() + if (readyState != null) { + FlipCameraButton( + modifier = modifier.testTag(FLIP_CAMERA_BUTTON), + onClick = onFlipCamera, + flipLensUiState = readyState.flipLensUiState, + enabledCondition = when (val flipLensUiState = readyState.flipLensUiState) { + is FlipLensUiState.Available -> flipLensUiState.availableLensFacings.size > 1 + FlipLensUiState.Unavailable -> false + } ) } } } - val elapsedTimeDisplayLambda = remember { + val zoomLevelDisplayLambda = remember(onAnimateZoom) { @Composable { modifier: Modifier -> - AnimatedVisibility( - visible = (captureUiStateProvider().videoRecordingState is VideoRecordingState.Active), - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) } - ElapsedTimeText( - modifier = elapsedTimeModifier, - elapsedTimeUiState = captureUiStateProvider().elapsedTimeUiState - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + ZoomButtonRow( + zoomControlUiState = readyState.zoomControlUiState, + onChangeZoom = { targetZoom -> onAnimateZoom(targetZoom, LensToZoom.PRIMARY) } + ) + } } } } - val captureButtonLambda = remember(onIncrementZoom, quickSettingsController, captureController) { + val elapsedTimeDisplayLambda = remember { @Composable { modifier: Modifier -> - fun runCaptureAction(action: () -> Unit) { - if ((quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true) { - quickSettingsController?.toggleQuickSettings() - } - action() - } - CaptureButton( - captureButtonUiState = captureUiStateProvider().captureButtonUiState, - isQuickSettingsOpen = (quickSettingsUiState as? QuickSettingsUiState.Available) - ?.quickSettingsIsOpen ?: false, - onCaptureImage = { - runCaptureAction { - captureController?.captureImage(it) - } - }, - onIncrementZoom = { targetZoom -> - onIncrementZoom(targetZoom, LensToZoom.PRIMARY) - }, - onStartVideoRecording = { - runCaptureAction { - captureController?.startVideoRecording() - } - }, - onStopVideoRecording = { captureController?.stopVideoRecording() }, - onLockVideoRecording = { isLocked -> - captureController?.setLockedRecording( - isLocked + val readyState = captureUiStateProvider() + if (readyState != null) { + AnimatedVisibility( + visible = (readyState.videoRecordingState is VideoRecordingState.Active), + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) } + ElapsedTimeText( + modifier = elapsedTimeModifier, + elapsedTimeUiState = readyState.elapsedTimeUiState ) } - ) - }, + } + } + } + val quickSettingsButtonLambda = remember(quickSettingsController) { @Composable { modifier: Modifier -> - AnimatedVisibility( - visible = (captureUiStateProvider().videoRecordingState !is VideoRecordingState.Active), - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - quickSettingsController?.let { quickSettingsController -> - ToggleQuickSettingsButton( - modifier = modifier, - isOpen = (captureUiStateProvider().quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true, - quickSettingsController = quickSettingsController - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + AnimatedVisibility( + visible = (readyState.videoRecordingState !is VideoRecordingState.Active), + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + quickSettingsController?.let { quickSettingsController -> + ToggleQuickSettingsButton( + modifier = modifier, + isOpen = (readyState.quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true, + quickSettingsController = quickSettingsController + ) + } } } } @@ -524,42 +556,50 @@ private fun ContentScreen( val audioToggleButtonLambda = remember(onToggleAudio) { @Composable { modifier: Modifier -> - AmplitudeToggleButton( - modifier = modifier, - onToggleAudio = onToggleAudio, - audioUiState = captureUiStateProvider().audioUiState - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + AmplitudeToggleButton( + modifier = modifier, + onToggleAudio = onToggleAudio, + audioUiState = readyState.audioUiState + ) + } } } val captureModeToggleLambda = remember(quickSettingsController, snackBarController) { @Composable { modifier: Modifier -> - val captureModeToggleUiState = captureUiStateProvider().captureModeToggleUiState - if (captureModeToggleUiState is CaptureModeToggleUiState.Available) { - CaptureModeToggleButton( - uiState = captureModeToggleUiState, - quickSettingsController = quickSettingsController, - snackBarController = snackBarController, - modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON) - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + val captureModeToggleUiState = readyState.captureModeToggleUiState + if (captureModeToggleUiState is CaptureModeToggleUiState.Available) { + CaptureModeToggleButton( + uiState = captureModeToggleUiState, + quickSettingsController = quickSettingsController, + snackBarController = snackBarController, + modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON) + ) + } } } } val quickSettingsOverlayLambda = remember(quickSettingsController, onNavigateToSettings) { @Composable { modifier: Modifier -> - quickSettingsController?.let { quickSettingsController -> - QuickSettingsBottomSheet( - modifier = modifier, - quickSettingsUiState = captureUiStateProvider().quickSettingsUiState, - onNavigateToSettings = { - quickSettingsController.toggleQuickSettings() - onNavigateToSettings() - }, - quickSettingsController = quickSettingsController - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + quickSettingsController?.let { quickSettingsController -> + QuickSettingsBottomSheet( + modifier = modifier, + quickSettingsUiState = readyState.quickSettingsUiState, + onNavigateToSettings = { + quickSettingsController.toggleQuickSettings() + onNavigateToSettings() + }, + quickSettingsController = quickSettingsController + ) + } } - Unit } } @@ -591,12 +631,15 @@ private fun ContentScreen( val screenFlashOverlayLambda = remember(screenFlashController) { @Composable { modifier: Modifier -> - ScreenFlashScreen( - screenFlashUiState = captureUiStateProvider().screenFlashUiState, - onInitialBrightnessCalculated = { - screenFlashController?.setClearUiScreenBrightness(it) - } - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + ScreenFlashScreen( + screenFlashUiState = readyState.screenFlashUiState, + onInitialBrightnessCalculated = { + screenFlashController?.setClearUiScreenBrightness(it) + } + ) + } } } @@ -621,27 +664,32 @@ private fun ContentScreen( val pauseToggleButtonLambda = remember(captureController) { @Composable { modifier: Modifier -> - PauseResumeToggleButton( - modifier = modifier, - onSetPause = captureController?.let { it::setPaused } ?: { _ -> }, - currentRecordingState = captureUiStateProvider().videoRecordingState - ) + val readyState = captureUiStateProvider() + if (readyState != null) { + PauseResumeToggleButton( + modifier = modifier, + onSetPause = captureController?.let { it::setPaused } ?: { _ -> }, + currentRecordingState = readyState.videoRecordingState + ) + } } } val imageWellLambda = remember(imageWellController, onNavigatePostCapture) { @Composable { modifier: Modifier -> val readyState = captureUiStateProvider() - if (readyState.externalCaptureMode == ExternalCaptureMode.Standard) { - (readyState.imageWellUiState as? ImageWellUiState.Content)?.let { contentState -> - ImageWell( - modifier = modifier, - imageWellUiState = contentState, - onClick = { - imageWellController?.imageWellToRepository(contentState.mediaDescriptor) - onNavigatePostCapture() - } - ) + if (readyState != null) { + if (readyState.externalCaptureMode == ExternalCaptureMode.Standard) { + (readyState.imageWellUiState as? ImageWellUiState.Content)?.let { contentState -> + ImageWell( + modifier = modifier, + imageWellUiState = contentState, + onClick = { + imageWellController?.imageWellToRepository(contentState.mediaDescriptor) + onNavigatePostCapture() + } + ) + } } } } @@ -653,6 +701,7 @@ private fun ContentScreen( flashModeIndicator = flashModeIndicatorLambda, videoQualityIndicator = videoQualityIndicatorLambda, stabilizationIndicator = stabilizationIndicatorLambda, + viewfinder = viewfinderLambda, captureButton = captureButtonLambda, flipCameraButton = flipCameraButtonLambda, From 6089c2bc15c8af61ed09a83bdfc1da4250eaaed3 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 22 May 2026 07:16:30 +0000 Subject: [PATCH 09/12] Apply manual fixes and spotless formatting --- .../feature/preview/PreviewScreen.kt | 338 ++++++++++-------- .../capture/CaptureScreenComponents.kt | 4 +- 2 files changed, 186 insertions(+), 156 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 724e2b144..ca0027296 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -59,7 +59,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tracing.Trace -import kotlin.time.Duration.Companion.nanoseconds import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted @@ -112,10 +111,8 @@ import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState import com.google.jetpackcamera.ui.uistate.capture.DebugUiState import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState -import com.google.jetpackcamera.ui.uistate.capture.ScreenFlashUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomControlUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomUiState -import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import kotlinx.coroutines.flow.transformWhile @@ -196,148 +193,148 @@ fun PreviewScreen( ) } - val context = LocalContext.current - LaunchedEffect(Unit) { - debouncedOrientationFlow(context).collect( - viewModel.cameraController::setDisplayRotation - ) - } - val scope = rememberCoroutineScope() - val zoomStateManager = remember { - // the initialZoomLevel must be fetched from the settings, not the cameraState. - // since we want to reset the ZoomState on flip, the zoomstate of the cameraState - // may not yet be congruent with the settings - - ZoomStateManager( - initialZoomLevel = ( - currentUiState.zoomControlUiState as? - ZoomControlUiState.Enabled - ) - ?.initialZoomRatio - ?: 1f, - zoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) - ?.primaryZoomRange - ?: Range(1f, 1f), - zoomController = viewModel.zoomController - ) - } + val context = LocalContext.current + LaunchedEffect(Unit) { + debouncedOrientationFlow(context).collect( + viewModel.cameraController::setDisplayRotation + ) + } + val scope = rememberCoroutineScope() + val zoomStateManager = remember { + // the initialZoomLevel must be fetched from the settings, not the cameraState. + // since we want to reset the ZoomState on flip, the zoomstate of the cameraState + // may not yet be congruent with the settings + + ZoomStateManager( + initialZoomLevel = ( + currentUiState.zoomControlUiState as? + ZoomControlUiState.Enabled + ) + ?.initialZoomRatio + ?: 1f, + zoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) + ?.primaryZoomRange + ?: Range(1f, 1f), + zoomController = viewModel.zoomController + ) + } - LaunchedEffect( - (currentUiState.flipLensUiState as? FlipLensUiState.Available) - ?.selectedLensFacing - ) { - zoomStateManager.onChangeLens( - newInitialZoomLevel = ( - currentUiState.zoomControlUiState as? - ZoomControlUiState.Enabled - ) - ?.initialZoomRatio - ?: 1f, - newZoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) - ?.primaryZoomRange - ?: Range(1f, 1f) - ) - } - // todo(kc) handle reset certain values after video recording is complete - LaunchedEffect(currentUiState.videoRecordingState) { - with(currentUiState.videoRecordingState) { - when (this) { - is VideoRecordingState.Starting -> { - initialRecordingSettings = this.initialRecordingSettings - } + LaunchedEffect( + (currentUiState.flipLensUiState as? FlipLensUiState.Available) + ?.selectedLensFacing + ) { + zoomStateManager.onChangeLens( + newInitialZoomLevel = ( + currentUiState.zoomControlUiState as? + ZoomControlUiState.Enabled + ) + ?.initialZoomRatio + ?: 1f, + newZoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) + ?.primaryZoomRange + ?: Range(1f, 1f) + ) + } + // todo(kc) handle reset certain values after video recording is complete + LaunchedEffect(currentUiState.videoRecordingState) { + with(currentUiState.videoRecordingState) { + when (this) { + is VideoRecordingState.Starting -> { + initialRecordingSettings = this.initialRecordingSettings + } - is VideoRecordingState.Inactive -> { - initialRecordingSettings?.let { - val oldPrimaryLensFacing = it.lensFacing - val oldZoomRatios = it.zoomRatios - val oldAudioEnabled = it.isAudioEnabled - Log.d(TAG, "reset pre recording settings") - viewModel.captureController.setAudioEnabled(oldAudioEnabled) - viewModel.quickSettingsController.setLensFacing( - oldPrimaryLensFacing + is VideoRecordingState.Inactive -> { + initialRecordingSettings?.let { + val oldPrimaryLensFacing = it.lensFacing + val oldZoomRatios = it.zoomRatios + val oldAudioEnabled = it.isAudioEnabled + Log.d(TAG, "reset pre recording settings") + viewModel.captureController.setAudioEnabled(oldAudioEnabled) + viewModel.quickSettingsController.setLensFacing( + oldPrimaryLensFacing + ) + zoomStateManager.apply { + absoluteZoom( + targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing] ?: 1f, + lensToZoom = LensToZoom.PRIMARY + ) + absoluteZoom( + targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing.flip()] + ?: 1f, + lensToZoom = LensToZoom.SECONDARY ) - zoomStateManager.apply { - absoluteZoom( - targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing] ?: 1f, - lensToZoom = LensToZoom.PRIMARY - ) - absoluteZoom( - targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing.flip()] - ?: 1f, - lensToZoom = LensToZoom.SECONDARY - ) - } } - initialRecordingSettings = null } - - is VideoRecordingState.Active -> {} + initialRecordingSettings = null } + + is VideoRecordingState.Active -> {} } } + } - ContentScreen( - modifier = modifier, - captureUiStateProvider = captureUiStateProvider, - surfaceRequest = surfaceRequest, - onNavigateToSettings = onNavigateToSettings, - - onAbsoluteZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> - scope.launch { - zoomStateManager.absoluteZoom( - zoomRatio, - lensToZoom - ) - } - }, - onScaleZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> - scope.launch { - zoomStateManager.scaleZoom( - zoomRatio, - lensToZoom - ) - } - }, - onAnimateZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> - scope.launch { - zoomStateManager.animatedZoom( - targetZoomLevel = zoomRatio, - lensToZoom = lensToZoom - ) - } - }, - onIncrementZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> - scope.launch { - zoomStateManager.incrementZoom( - zoomRatio, - lensToZoom - ) - } - }, - onRequestWindowColorMode = onRequestWindowColorMode, - onNavigatePostCapture = onNavigateToPostCapture, - debugUiState = debugUiState, - snackBarUiState = snackBarUiState, - debugController = viewModel.debugController, - snackBarController = viewModel.snackBarController, - quickSettingsController = viewModel.quickSettingsController, - captureController = viewModel.captureController, - imageWellController = viewModel.imageWellController, - cameraController = viewModel.cameraController, - screenFlashController = viewModel.screenFlashController - ) - val readStoragePermission: PermissionState = rememberPermissionState( - Manifest.permission.READ_EXTERNAL_STORAGE - ) - - LaunchedEffect(readStoragePermission.status) { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || - readStoragePermission.status.isGranted - ) { - viewModel.imageWellController.updateLastCapturedMedia() + ContentScreen( + modifier = modifier, + captureUiStateProvider = captureUiStateProvider, + surfaceRequest = surfaceRequest, + onNavigateToSettings = onNavigateToSettings, + + onAbsoluteZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> + scope.launch { + zoomStateManager.absoluteZoom( + zoomRatio, + lensToZoom + ) + } + }, + onScaleZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> + scope.launch { + zoomStateManager.scaleZoom( + zoomRatio, + lensToZoom + ) } + }, + onAnimateZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> + scope.launch { + zoomStateManager.animatedZoom( + targetZoomLevel = zoomRatio, + lensToZoom = lensToZoom + ) + } + }, + onIncrementZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> + scope.launch { + zoomStateManager.incrementZoom( + zoomRatio, + lensToZoom + ) + } + }, + onRequestWindowColorMode = onRequestWindowColorMode, + onNavigatePostCapture = onNavigateToPostCapture, + debugUiState = debugUiState, + snackBarUiState = snackBarUiState, + debugController = viewModel.debugController, + snackBarController = viewModel.snackBarController, + quickSettingsController = viewModel.quickSettingsController, + captureController = viewModel.captureController, + imageWellController = viewModel.imageWellController, + cameraController = viewModel.cameraController, + screenFlashController = viewModel.screenFlashController + ) + val readStoragePermission: PermissionState = rememberPermissionState( + Manifest.permission.READ_EXTERNAL_STORAGE + ) + + LaunchedEffect(readStoragePermission.status) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || + readStoragePermission.status.isGranted + ) { + viewModel.imageWellController.updateLastCapturedMedia() } } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -368,7 +365,8 @@ private fun ContentScreen( val readyState = captureUiStateProvider() if (readyState?.flipLensUiState is FlipLensUiState.Available) { quickSettingsController?.setLensFacing( - (readyState.flipLensUiState as FlipLensUiState.Available).selectedLensFacing.flip() + (readyState.flipLensUiState as FlipLensUiState.Available) + .selectedLensFacing.flip() ) } } @@ -426,11 +424,20 @@ private fun ContentScreen( } } - val onTapToFocusLambda = cameraController?.let { it::tapToFocus } ?: remember { { _: Float, _: Float -> } } + val onTapToFocusLambda = cameraController?.let { it::tapToFocus } + ?: remember { { _: Float, _: Float -> } } val currentOnScaleZoom = rememberUpdatedState(onScaleZoom) - val onScaleZoomLambda = remember { { zoomRatio: Float -> currentOnScaleZoom.value(zoomRatio, LensToZoom.PRIMARY) } } + val onScaleZoomLambda = remember { + { zoomRatio: Float -> currentOnScaleZoom.value(zoomRatio, LensToZoom.PRIMARY) } + } - val viewfinderLambda = remember(onFlipCamera, onTapToFocusLambda, onScaleZoomLambda, surfaceRequest, onRequestWindowColorMode) { + val viewfinderLambda = remember( + onFlipCamera, + onTapToFocusLambda, + onScaleZoomLambda, + surfaceRequest, + onRequestWindowColorMode + ) { @Composable { modifier: Modifier -> val readyState = captureUiStateProvider() if (readyState != null) { @@ -447,20 +454,28 @@ private fun ContentScreen( } } - val captureButtonLambda = remember(onIncrementZoom, quickSettingsController, captureController) { + val captureButtonLambda = remember( + onIncrementZoom, + quickSettingsController, + captureController + ) { @Composable { modifier: Modifier -> val readyState = captureUiStateProvider() if (readyState != null) { val quickSettingsUiState = readyState.quickSettingsUiState fun runCaptureAction(action: () -> Unit) { - if ((quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true) { + if ((quickSettingsUiState as? QuickSettingsUiState.Available) + ?.quickSettingsIsOpen == true + ) { quickSettingsController?.toggleQuickSettings() } action() } CaptureButton( captureButtonUiState = readyState.captureButtonUiState, - isQuickSettingsOpen = (quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen ?: false, + isQuickSettingsOpen = + (quickSettingsUiState as? QuickSettingsUiState.Available) + ?.quickSettingsIsOpen ?: false, onCaptureImage = { runCaptureAction { captureController?.captureImage(it) @@ -491,8 +506,10 @@ private fun ContentScreen( modifier = modifier.testTag(FLIP_CAMERA_BUTTON), onClick = onFlipCamera, flipLensUiState = readyState.flipLensUiState, - enabledCondition = when (val flipLensUiState = readyState.flipLensUiState) { - is FlipLensUiState.Available -> flipLensUiState.availableLensFacings.size > 1 + enabledCondition = + when (val flipLensUiState = readyState.flipLensUiState) { + is FlipLensUiState.Available -> + flipLensUiState.availableLensFacings.size > 1 FlipLensUiState.Unavailable -> false } ) @@ -507,7 +524,9 @@ private fun ContentScreen( Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { ZoomButtonRow( zoomControlUiState = readyState.zoomControlUiState, - onChangeZoom = { targetZoom -> onAnimateZoom(targetZoom, LensToZoom.PRIMARY) } + onChangeZoom = { targetZoom -> + onAnimateZoom(targetZoom, LensToZoom.PRIMARY) + } ) } } @@ -523,7 +542,9 @@ private fun ContentScreen( enter = fadeIn(), exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) ) { - val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) } + val elapsedTimeModifier = remember(modifier) { + modifier.testTag(ELAPSED_TIME_TAG) + } ElapsedTimeText( modifier = elapsedTimeModifier, elapsedTimeUiState = readyState.elapsedTimeUiState @@ -545,7 +566,11 @@ private fun ContentScreen( quickSettingsController?.let { quickSettingsController -> ToggleQuickSettingsButton( modifier = modifier, - isOpen = (readyState.quickSettingsUiState as? QuickSettingsUiState.Available)?.quickSettingsIsOpen == true, + isOpen = ( + readyState.quickSettingsUiState + as? QuickSettingsUiState.Available + ) + ?.quickSettingsIsOpen == true, quickSettingsController = quickSettingsController ) } @@ -680,16 +705,19 @@ private fun ContentScreen( val readyState = captureUiStateProvider() if (readyState != null) { if (readyState.externalCaptureMode == ExternalCaptureMode.Standard) { - (readyState.imageWellUiState as? ImageWellUiState.Content)?.let { contentState -> - ImageWell( - modifier = modifier, - imageWellUiState = contentState, - onClick = { - imageWellController?.imageWellToRepository(contentState.mediaDescriptor) - onNavigatePostCapture() - } - ) - } + (readyState.imageWellUiState as? ImageWellUiState.Content) + ?.let { contentState -> + ImageWell( + modifier = modifier, + imageWellUiState = contentState, + onClick = { + imageWellController?.imageWellToRepository( + contentState.mediaDescriptor + ) + onNavigatePostCapture() + } + ) + } } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 41475c111..6446b7c11 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -144,7 +144,9 @@ fun ElapsedTimeText(modifier: Modifier = Modifier, elapsedTimeUiState: ElapsedTi modifier = modifier, text = formattedTime, textAlign = TextAlign.Center, - style = androidx.compose.material3.LocalTextStyle.current.copy(fontFeatureSettings = "tnum") + style = androidx.compose.material3.LocalTextStyle.current.copy( + fontFeatureSettings = "tnum" + ) ) } } From c2fdb471cfd718f10351f2a16436add42f7c9e47 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 22 May 2026 17:03:11 +0000 Subject: [PATCH 10/12] Resolve PR 515 comments: optimize recompositions in PreviewScreen, clean up imports and update KDoc/signature for ElapsedTimeText --- .../feature/preview/PreviewScreen.kt | 44 ++++++++++++------- .../capture/CaptureScreenComponents.kt | 10 +++-- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index ca0027296..c9c70fff5 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -20,6 +20,7 @@ import android.os.Build import android.util.Log import android.util.Range import androidx.camera.core.SurfaceRequest +import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -236,7 +237,16 @@ fun PreviewScreen( ) } // todo(kc) handle reset certain values after video recording is complete - LaunchedEffect(currentUiState.videoRecordingState) { + val recordingStateKey = remember { + derivedStateOf { + when (currentUiState.videoRecordingState) { + is VideoRecordingState.Starting -> 1 + is VideoRecordingState.Inactive -> 2 + else -> 0 + } + } + } + LaunchedEffect(recordingStateKey.value) { with(currentUiState.videoRecordingState) { when (this) { is VideoRecordingState.Starting -> { @@ -535,21 +545,25 @@ private fun ContentScreen( val elapsedTimeDisplayLambda = remember { @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - AnimatedVisibility( - visible = (readyState.videoRecordingState is VideoRecordingState.Active), - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - val elapsedTimeModifier = remember(modifier) { - modifier.testTag(ELAPSED_TIME_TAG) - } - ElapsedTimeText( - modifier = elapsedTimeModifier, - elapsedTimeUiState = readyState.elapsedTimeUiState - ) + val isVisible = remember { + derivedStateOf { + captureUiStateProvider()?.videoRecordingState is VideoRecordingState.Active + } + } + AnimatedVisibility( + visible = isVisible.value, + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + val elapsedTimeModifier = remember(modifier) { + modifier.testTag(ELAPSED_TIME_TAG) } + ElapsedTimeText( + modifier = elapsedTimeModifier, + formattedTimeProvider = { + captureUiStateProvider()?.elapsedTimeUiState ?: ElapsedTimeUiState.Unavailable + } + ) } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 6446b7c11..9117253f9 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -62,6 +62,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text @@ -131,8 +132,11 @@ private const val FOCUS_INDICATOR_RESULT_DELAY = 100L * @param formattedTimeProvider a provider for the formatted time string. */ @Composable -fun ElapsedTimeText(modifier: Modifier = Modifier, elapsedTimeUiState: ElapsedTimeUiState) { - val formattedTime = when (elapsedTimeUiState) { +fun ElapsedTimeText( + modifier: Modifier = Modifier, + formattedTimeProvider: () -> ElapsedTimeUiState +) { + val formattedTime = when (val elapsedTimeUiState = formattedTimeProvider()) { is ElapsedTimeUiState.Enabled -> { elapsedTimeUiState.elapsedTimeNanos.nanoseconds .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) } @@ -144,7 +148,7 @@ fun ElapsedTimeText(modifier: Modifier = Modifier, elapsedTimeUiState: ElapsedTi modifier = modifier, text = formattedTime, textAlign = TextAlign.Center, - style = androidx.compose.material3.LocalTextStyle.current.copy( + style = LocalTextStyle.current.copy( fontFeatureSettings = "tnum" ) ) From 04b82dfd06897f59f1426c91f950007ce1f325c5 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 22 May 2026 17:12:01 +0000 Subject: [PATCH 11/12] Apply line length fix and spotless formatting to PR 515 --- .../google/jetpackcamera/feature/preview/PreviewScreen.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index c9c70fff5..6c43007a5 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -20,7 +20,6 @@ import android.os.Build import android.util.Log import android.util.Range import androidx.camera.core.SurfaceRequest -import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -110,6 +109,7 @@ import com.google.jetpackcamera.ui.uistate.capture.AudioUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState import com.google.jetpackcamera.ui.uistate.capture.DebugUiState +import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomControlUiState @@ -561,7 +561,8 @@ private fun ContentScreen( ElapsedTimeText( modifier = elapsedTimeModifier, formattedTimeProvider = { - captureUiStateProvider()?.elapsedTimeUiState ?: ElapsedTimeUiState.Unavailable + captureUiStateProvider()?.elapsedTimeUiState + ?: ElapsedTimeUiState.Unavailable } ) } From 7158c5aa960e007ee3fbb6f5f1ae6a1837a5bb46 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Sat, 30 May 2026 07:45:07 +0000 Subject: [PATCH 12/12] Optimize ElapsedTimeText recompositions and enable tabular figures --- .../feature/preview/PreviewScreen.kt | 841 +++++++----------- 1 file changed, 344 insertions(+), 497 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 6c43007a5..90cfbe02a 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -109,9 +108,9 @@ import com.google.jetpackcamera.ui.uistate.capture.AudioUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState import com.google.jetpackcamera.ui.uistate.capture.DebugUiState -import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState +import com.google.jetpackcamera.ui.uistate.capture.ScreenFlashUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomControlUiState import com.google.jetpackcamera.ui.uistate.capture.ZoomUiState import com.google.jetpackcamera.ui.uistate.capture.compound.CaptureUiState @@ -137,12 +136,12 @@ fun PreviewScreen( ) { Log.d(TAG, "PreviewScreen") - val captureUiStateState = viewModel.captureUiState.collectAsState() + val captureUiState: CaptureUiState by viewModel.captureUiState.collectAsState() val debugUiState: DebugUiState by viewModel.debugUiState.collectAsState() val snackBarUiState: SnackBarUiState by viewModel.snackBarUiState.collectAsState() - val captureUiStateProvider = remember { { captureUiStateState.value as? CaptureUiState.Ready } } - val isReady by remember { derivedStateOf { captureUiStateState.value is CaptureUiState.Ready } } + val screenFlashUiState = + (captureUiState as? CaptureUiState.Ready)?.screenFlashUiState ?: ScreenFlashUiState() val surfaceRequest: SurfaceRequest? by viewModel.surfaceRequest.collectAsState() @@ -168,7 +167,7 @@ fun PreviewScreen( if (Trace.isEnabled()) { LaunchedEffect(onFirstFrameCaptureCompleted) { - snapshotFlow { captureUiStateState.value } + snapshotFlow { captureUiState } .transformWhile { var continueCollecting = true (it as? CaptureUiState.Ready)?.let { ready -> @@ -184,164 +183,156 @@ fun PreviewScreen( } } - if (!isReady) { - LoadingScreen() - } else { - val currentUiState = captureUiStateState.value as CaptureUiState.Ready - var initialRecordingSettings by remember { - mutableStateOf( - null - ) - } + when (val currentUiState = captureUiState) { + is CaptureUiState.NotReady -> LoadingScreen() + is CaptureUiState.Ready -> { + var initialRecordingSettings by remember { + mutableStateOf( + null + ) + } - val context = LocalContext.current - LaunchedEffect(Unit) { - debouncedOrientationFlow(context).collect( - viewModel.cameraController::setDisplayRotation - ) - } - val scope = rememberCoroutineScope() - val zoomStateManager = remember { - // the initialZoomLevel must be fetched from the settings, not the cameraState. - // since we want to reset the ZoomState on flip, the zoomstate of the cameraState - // may not yet be congruent with the settings - - ZoomStateManager( - initialZoomLevel = ( - currentUiState.zoomControlUiState as? - ZoomControlUiState.Enabled - ) - ?.initialZoomRatio - ?: 1f, - zoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) - ?.primaryZoomRange - ?: Range(1f, 1f), - zoomController = viewModel.zoomController - ) - } + val context = LocalContext.current + LaunchedEffect(Unit) { + debouncedOrientationFlow(context).collect( + viewModel.cameraController::setDisplayRotation + ) + } + val scope = rememberCoroutineScope() + val zoomStateManager = remember { + // the initialZoomLevel must be fetched from the settings, not the cameraState. + // since we want to reset the ZoomState on flip, the zoomstate of the cameraState + // may not yet be congruent with the settings + + ZoomStateManager( + initialZoomLevel = ( + currentUiState.zoomControlUiState as? + ZoomControlUiState.Enabled + ) + ?.initialZoomRatio + ?: 1f, + zoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) + ?.primaryZoomRange + ?: Range(1f, 1f), + zoomController = viewModel.zoomController + ) + } - LaunchedEffect( - (currentUiState.flipLensUiState as? FlipLensUiState.Available) - ?.selectedLensFacing - ) { - zoomStateManager.onChangeLens( - newInitialZoomLevel = ( - currentUiState.zoomControlUiState as? - ZoomControlUiState.Enabled - ) - ?.initialZoomRatio - ?: 1f, - newZoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) - ?.primaryZoomRange - ?: Range(1f, 1f) - ) - } - // todo(kc) handle reset certain values after video recording is complete - val recordingStateKey = remember { - derivedStateOf { - when (currentUiState.videoRecordingState) { - is VideoRecordingState.Starting -> 1 - is VideoRecordingState.Inactive -> 2 - else -> 0 - } + LaunchedEffect( + (currentUiState.flipLensUiState as? FlipLensUiState.Available) + ?.selectedLensFacing + ) { + zoomStateManager.onChangeLens( + newInitialZoomLevel = ( + currentUiState.zoomControlUiState as? + ZoomControlUiState.Enabled + ) + ?.initialZoomRatio + ?: 1f, + newZoomRange = (currentUiState.zoomUiState as? ZoomUiState.Enabled) + ?.primaryZoomRange + ?: Range(1f, 1f) + ) } - } - LaunchedEffect(recordingStateKey.value) { - with(currentUiState.videoRecordingState) { - when (this) { - is VideoRecordingState.Starting -> { - initialRecordingSettings = this.initialRecordingSettings - } + // todo(kc) handle reset certain values after video recording is complete + LaunchedEffect(currentUiState.videoRecordingState) { + with(currentUiState.videoRecordingState) { + when (this) { + is VideoRecordingState.Starting -> { + initialRecordingSettings = this.initialRecordingSettings + } - is VideoRecordingState.Inactive -> { - initialRecordingSettings?.let { - val oldPrimaryLensFacing = it.lensFacing - val oldZoomRatios = it.zoomRatios - val oldAudioEnabled = it.isAudioEnabled - Log.d(TAG, "reset pre recording settings") - viewModel.captureController.setAudioEnabled(oldAudioEnabled) - viewModel.quickSettingsController.setLensFacing( - oldPrimaryLensFacing - ) - zoomStateManager.apply { - absoluteZoom( - targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing] ?: 1f, - lensToZoom = LensToZoom.PRIMARY - ) - absoluteZoom( - targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing.flip()] - ?: 1f, - lensToZoom = LensToZoom.SECONDARY + is VideoRecordingState.Inactive -> { + initialRecordingSettings?.let { + val oldPrimaryLensFacing = it.lensFacing + val oldZoomRatios = it.zoomRatios + val oldAudioEnabled = it.isAudioEnabled + Log.d(TAG, "reset pre recording settings") + viewModel.captureController.setAudioEnabled(oldAudioEnabled) + viewModel.quickSettingsController.setLensFacing( + oldPrimaryLensFacing ) + zoomStateManager.apply { + absoluteZoom( + targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing] ?: 1f, + lensToZoom = LensToZoom.PRIMARY + ) + absoluteZoom( + targetZoomLevel = oldZoomRatios[oldPrimaryLensFacing.flip()] + ?: 1f, + lensToZoom = LensToZoom.SECONDARY + ) + } } + initialRecordingSettings = null } - initialRecordingSettings = null - } - is VideoRecordingState.Active -> {} + is VideoRecordingState.Active -> {} + } } } - } - ContentScreen( - modifier = modifier, - captureUiStateProvider = captureUiStateProvider, - surfaceRequest = surfaceRequest, - onNavigateToSettings = onNavigateToSettings, - - onAbsoluteZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> - scope.launch { - zoomStateManager.absoluteZoom( - zoomRatio, - lensToZoom - ) - } - }, - onScaleZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> - scope.launch { - zoomStateManager.scaleZoom( - zoomRatio, - lensToZoom - ) - } - }, - onAnimateZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> - scope.launch { - zoomStateManager.animatedZoom( - targetZoomLevel = zoomRatio, - lensToZoom = lensToZoom - ) - } - }, - onIncrementZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> - scope.launch { - zoomStateManager.incrementZoom( - zoomRatio, - lensToZoom - ) - } - }, - onRequestWindowColorMode = onRequestWindowColorMode, - onNavigatePostCapture = onNavigateToPostCapture, - debugUiState = debugUiState, - snackBarUiState = snackBarUiState, - debugController = viewModel.debugController, - snackBarController = viewModel.snackBarController, - quickSettingsController = viewModel.quickSettingsController, - captureController = viewModel.captureController, - imageWellController = viewModel.imageWellController, - cameraController = viewModel.cameraController, - screenFlashController = viewModel.screenFlashController - ) - val readStoragePermission: PermissionState = rememberPermissionState( - Manifest.permission.READ_EXTERNAL_STORAGE - ) + ContentScreen( + modifier = modifier, + captureUiState = currentUiState, + screenFlashUiState = screenFlashUiState, + surfaceRequest = surfaceRequest, + onNavigateToSettings = onNavigateToSettings, + + onAbsoluteZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> + scope.launch { + zoomStateManager.absoluteZoom( + zoomRatio, + lensToZoom + ) + } + }, + onScaleZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> + scope.launch { + zoomStateManager.scaleZoom( + zoomRatio, + lensToZoom + ) + } + }, + onAnimateZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> + scope.launch { + zoomStateManager.animatedZoom( + targetZoomLevel = zoomRatio, + lensToZoom = lensToZoom + ) + } + }, + onIncrementZoom = { zoomRatio: Float, lensToZoom: LensToZoom -> + scope.launch { + zoomStateManager.incrementZoom( + zoomRatio, + lensToZoom + ) + } + }, + onRequestWindowColorMode = onRequestWindowColorMode, + onNavigatePostCapture = onNavigateToPostCapture, + debugUiState = debugUiState, + snackBarUiState = snackBarUiState, + debugController = viewModel.debugController, + snackBarController = viewModel.snackBarController, + quickSettingsController = viewModel.quickSettingsController, + captureController = viewModel.captureController, + imageWellController = viewModel.imageWellController, + cameraController = viewModel.cameraController, + screenFlashController = viewModel.screenFlashController + ) + val readStoragePermission: PermissionState = rememberPermissionState( + Manifest.permission.READ_EXTERNAL_STORAGE + ) - LaunchedEffect(readStoragePermission.status) { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || - readStoragePermission.status.isGranted - ) { - viewModel.imageWellController.updateLastCapturedMedia() + LaunchedEffect(readStoragePermission.status) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || + readStoragePermission.status.isGranted + ) { + viewModel.imageWellController.updateLastCapturedMedia() + } } } } @@ -350,7 +341,8 @@ fun PreviewScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ContentScreen( - captureUiStateProvider: () -> CaptureUiState.Ready?, + captureUiState: CaptureUiState.Ready, + screenFlashUiState: ScreenFlashUiState, surfaceRequest: SurfaceRequest?, modifier: Modifier = Modifier, onNavigateToSettings: () -> Unit = {}, @@ -370,20 +362,19 @@ private fun ContentScreen( cameraController: CameraController? = null, screenFlashController: ScreenFlashController? = null ) { - val onFlipCamera = remember { - { - val readyState = captureUiStateProvider() - if (readyState?.flipLensUiState is FlipLensUiState.Available) { - quickSettingsController?.setLensFacing( - (readyState.flipLensUiState as FlipLensUiState.Available) - .selectedLensFacing.flip() - ) - } + val onFlipCamera = { + if (captureUiState.flipLensUiState is FlipLensUiState.Available) { + quickSettingsController?.setLensFacing( + ( + captureUiState.flipLensUiState as FlipLensUiState.Available + ) + .selectedLensFacing.flip() + ) } } - val isAudioEnabled by remember { - derivedStateOf { captureUiStateProvider()?.audioUiState is AudioUiState.Enabled.On } + val isAudioEnabled = remember(captureUiState) { + captureUiState.audioUiState is AudioUiState.Enabled.On } val onToggleAudio: () -> Unit = remember(isAudioEnabled) { { @@ -391,261 +382,162 @@ private fun ContentScreen( } } - // Remember content slot lambdas to avoid unnecessary recompositions - val hdrIndicatorLambda = remember { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - HdrIndicator(modifier = modifier, hdrUiState = readyState.hdrUiState) - } - } - } - val flashModeIndicatorLambda = remember { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - FlashModeIndicator( - modifier = modifier, - flashModeUiState = readyState.flashModeUiState - ) - } - } - } - val videoQualityIndicatorLambda = remember { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - VideoQualityIcon( - readyState.videoQuality, - modifier.testTag(VIDEO_QUALITY_TAG) - ) - } - } - } - val stabilizationIndicatorLambda = remember { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - StabilizationIcon( - modifier = modifier, - stabilizationUiState = readyState.stabilizationUiState - ) - } - } - } - - val onTapToFocusLambda = cameraController?.let { it::tapToFocus } - ?: remember { { _: Float, _: Float -> } } - val currentOnScaleZoom = rememberUpdatedState(onScaleZoom) - val onScaleZoomLambda = remember { - { zoomRatio: Float -> currentOnScaleZoom.value(zoomRatio, LensToZoom.PRIMARY) } - } - - val viewfinderLambda = remember( - onFlipCamera, - onTapToFocusLambda, - onScaleZoomLambda, - surfaceRequest, - onRequestWindowColorMode - ) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - PreviewDisplay( - previewDisplayUiState = readyState.previewDisplayUiState, - onFlipCamera = onFlipCamera, - onTapToFocus = onTapToFocusLambda, - onScaleZoom = onScaleZoomLambda, - surfaceRequest = surfaceRequest, - onRequestWindowColorMode = onRequestWindowColorMode, - focusMeteringUiState = readyState.focusMeteringUiState - ) - } - } - } + LayoutWrapper( + modifier = modifier, + hdrIndicator = { HdrIndicator(modifier = it, hdrUiState = captureUiState.hdrUiState) }, + flashModeIndicator = { + FlashModeIndicator( + modifier = it, + flashModeUiState = captureUiState.flashModeUiState + ) + }, + videoQualityIndicator = { + VideoQualityIcon( + captureUiState.videoQuality, + Modifier.testTag(VIDEO_QUALITY_TAG) + ) + }, + stabilizationIndicator = { + StabilizationIcon( + modifier = it, + stabilizationUiState = captureUiState.stabilizationUiState + ) + }, - val captureButtonLambda = remember( - onIncrementZoom, - quickSettingsController, - captureController - ) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - val quickSettingsUiState = readyState.quickSettingsUiState - fun runCaptureAction(action: () -> Unit) { - if ((quickSettingsUiState as? QuickSettingsUiState.Available) - ?.quickSettingsIsOpen == true - ) { - quickSettingsController?.toggleQuickSettings() - } - action() + viewfinder = { + PreviewDisplay( + previewDisplayUiState = captureUiState.previewDisplayUiState, + onFlipCamera = onFlipCamera, + onTapToFocus = cameraController?.let { it::tapToFocus } ?: { _, _ -> }, + onScaleZoom = { onScaleZoom(it, LensToZoom.PRIMARY) }, + surfaceRequest = surfaceRequest, + onRequestWindowColorMode = onRequestWindowColorMode, + focusMeteringUiState = captureUiState.focusMeteringUiState + ) + }, + captureButton = { + fun runCaptureAction(action: () -> Unit) { + if ((captureUiState.quickSettingsUiState as? QuickSettingsUiState.Available) + ?.quickSettingsIsOpen == true + ) { + quickSettingsController?.toggleQuickSettings() } - CaptureButton( - captureButtonUiState = readyState.captureButtonUiState, - isQuickSettingsOpen = - (quickSettingsUiState as? QuickSettingsUiState.Available) - ?.quickSettingsIsOpen ?: false, - onCaptureImage = { - runCaptureAction { - captureController?.captureImage(it) - } - }, - onIncrementZoom = { targetZoom -> - onIncrementZoom(targetZoom, LensToZoom.PRIMARY) - }, - onStartVideoRecording = { - runCaptureAction { - captureController?.startVideoRecording() - } - }, - onStopVideoRecording = { captureController?.stopVideoRecording() }, - onLockVideoRecording = { isLocked -> - captureController?.setLockedRecording(isLocked) - } - ) + action() } - } - } - - val flipCameraButtonLambda = remember(onFlipCamera) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - FlipCameraButton( - modifier = modifier.testTag(FLIP_CAMERA_BUTTON), - onClick = onFlipCamera, - flipLensUiState = readyState.flipLensUiState, - enabledCondition = - when (val flipLensUiState = readyState.flipLensUiState) { - is FlipLensUiState.Available -> - flipLensUiState.availableLensFacings.size > 1 - FlipLensUiState.Unavailable -> false + CaptureButton( + captureButtonUiState = captureUiState.captureButtonUiState, + isQuickSettingsOpen = ( + captureUiState.quickSettingsUiState as? + QuickSettingsUiState.Available + )?.quickSettingsIsOpen ?: false, + onCaptureImage = { + runCaptureAction { + captureController?.captureImage(it) } - ) - } - } - } - - val zoomLevelDisplayLambda = remember(onAnimateZoom) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { - ZoomButtonRow( - zoomControlUiState = readyState.zoomControlUiState, - onChangeZoom = { targetZoom -> - onAnimateZoom(targetZoom, LensToZoom.PRIMARY) - } + }, + onIncrementZoom = { targetZoom -> + onIncrementZoom(targetZoom, LensToZoom.PRIMARY) + }, + onStartVideoRecording = { + runCaptureAction { + captureController?.startVideoRecording() + } + }, + onStopVideoRecording = { captureController?.stopVideoRecording() }, + onLockVideoRecording = { isLocked -> + captureController?.setLockedRecording( + isLocked ) } - } - } - } - - val elapsedTimeDisplayLambda = remember { - @Composable { modifier: Modifier -> - val isVisible = remember { - derivedStateOf { - captureUiStateProvider()?.videoRecordingState is VideoRecordingState.Active + ) + }, + flipCameraButton = { + FlipCameraButton( + modifier = Modifier.testTag(FLIP_CAMERA_BUTTON), + onClick = onFlipCamera, + flipLensUiState = captureUiState.flipLensUiState, + // enable only when phone has front and rear camera + enabledCondition = when (val flipLensUiState = captureUiState.flipLensUiState) { + is FlipLensUiState.Available -> flipLensUiState.availableLensFacings.size > 1 + FlipLensUiState.Unavailable -> false } + ) + }, + zoomLevelDisplay = { + Column(modifier = Modifier, horizontalAlignment = Alignment.CenterHorizontally) { + ZoomButtonRow( + zoomControlUiState = captureUiState.zoomControlUiState, + onChangeZoom = { targetZoom -> + onAnimateZoom(targetZoom, LensToZoom.PRIMARY) + } + ) } + }, + elapsedTimeDisplay = { AnimatedVisibility( - visible = isVisible.value, + visible = (captureUiState.videoRecordingState is VideoRecordingState.Active), enter = fadeIn(), exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) ) { - val elapsedTimeModifier = remember(modifier) { - modifier.testTag(ELAPSED_TIME_TAG) - } ElapsedTimeText( - modifier = elapsedTimeModifier, - formattedTimeProvider = { - captureUiStateProvider()?.elapsedTimeUiState - ?: ElapsedTimeUiState.Unavailable - } + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiState = captureUiState.elapsedTimeUiState ) } - } - } + }, + quickSettingsButton = { + AnimatedVisibility( + visible = (captureUiState.videoRecordingState !is VideoRecordingState.Active), + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + quickSettingsController?.let { quickSettingsController -> + ToggleQuickSettingsButton( + modifier = it, + isOpen = ( + captureUiState.quickSettingsUiState + as? QuickSettingsUiState.Available + )?.quickSettingsIsOpen == true, + quickSettingsController = quickSettingsController - val quickSettingsButtonLambda = remember(quickSettingsController) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - AnimatedVisibility( - visible = (readyState.videoRecordingState !is VideoRecordingState.Active), - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - quickSettingsController?.let { quickSettingsController -> - ToggleQuickSettingsButton( - modifier = modifier, - isOpen = ( - readyState.quickSettingsUiState - as? QuickSettingsUiState.Available - ) - ?.quickSettingsIsOpen == true, - quickSettingsController = quickSettingsController - ) - } + ) } } - } - } - - val audioToggleButtonLambda = remember(onToggleAudio) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - AmplitudeToggleButton( - modifier = modifier, - onToggleAudio = onToggleAudio, - audioUiState = readyState.audioUiState + }, + audioToggleButton = { + AmplitudeToggleButton( + modifier = it, + onToggleAudio = onToggleAudio, + audioUiState = captureUiState.audioUiState + ) + }, + captureModeToggle = { + if (captureUiState.captureModeToggleUiState is CaptureModeToggleUiState.Available) { + CaptureModeToggleButton( + uiState = captureUiState.captureModeToggleUiState + as CaptureModeToggleUiState.Available, + + quickSettingsController = quickSettingsController, + snackBarController = snackBarController, + modifier = it.testTag(CAPTURE_MODE_TOGGLE_BUTTON) ) } - } - } - - val captureModeToggleLambda = remember(quickSettingsController, snackBarController) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - val captureModeToggleUiState = readyState.captureModeToggleUiState - if (captureModeToggleUiState is CaptureModeToggleUiState.Available) { - CaptureModeToggleButton( - uiState = captureModeToggleUiState, - quickSettingsController = quickSettingsController, - snackBarController = snackBarController, - modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON) - ) - } - } - } - } - - val quickSettingsOverlayLambda = remember(quickSettingsController, onNavigateToSettings) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - quickSettingsController?.let { quickSettingsController -> - QuickSettingsBottomSheet( - modifier = modifier, - quickSettingsUiState = readyState.quickSettingsUiState, - onNavigateToSettings = { - quickSettingsController.toggleQuickSettings() - onNavigateToSettings() - }, - quickSettingsController = quickSettingsController - ) - } + }, + quickSettingsOverlay = { + quickSettingsController?.let { quickSettingsController -> + QuickSettingsBottomSheet( + modifier = it, + quickSettingsUiState = captureUiState.quickSettingsUiState, + onNavigateToSettings = { + quickSettingsController.toggleQuickSettings() + onNavigateToSettings() + }, + quickSettingsController = quickSettingsController + ) } - } - } - - val debugOverlayLambda = remember(debugController, onAbsoluteZoom, debugUiState) { - @Composable { modifier: Modifier, extraControls: Array<@Composable () -> Unit>? -> - if (debugUiState is DebugUiState.Enabled) { + }, + debugOverlay = { modifier, extraControls -> + (debugUiState as? DebugUiState.Enabled)?.let { debugUiState -> debugController?.let { debugController -> DebugOverlay( modifier = modifier, @@ -656,36 +548,29 @@ private fun ContentScreen( ) } } - Unit - } - } - - val debugVisibilityWrapperLambda = remember(debugUiState) { - @Composable { content: @Composable () -> Unit -> - if (debugUiState !is DebugUiState.Enabled || !debugUiState.debugHidingComponents) { + }, + debugVisibilityWrapper = { content -> + val uiState = debugUiState + if (uiState !is DebugUiState.Enabled || !uiState.debugHidingComponents) { content() } - Unit - } - } - - val screenFlashOverlayLambda = remember(screenFlashController) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - ScreenFlashScreen( - screenFlashUiState = readyState.screenFlashUiState, - onInitialBrightnessCalculated = { - screenFlashController?.setClearUiScreenBrightness(it) - } - ) - } - } - } - - val snackBarLambda = remember(snackBarController) { - @Composable { modifier: Modifier, snackbarHostState: SnackbarHostState -> - val snackBarUiState = snackBarUiState + }, + screenFlashOverlay = { + // Screen flash overlay that stays on top of everything but invisible normally. This should + // not be enabled based on whether screen flash is enabled because a previous image capture + // may still be running after flash mode change and clear actions (e.g. brightness restore) + // may need to be handled later. Compose smart recomposition should be able to optimize this + // if the relevant states are no longer changing. + ScreenFlashScreen( + screenFlashUiState = screenFlashUiState, + onInitialBrightnessCalculated = { + screenFlashController?.setClearUiScreenBrightness( + it + ) + } + ) + }, + snackBar = { modifier, snackbarHostState -> if (snackBarUiState is SnackBarUiState.Enabled) { val snackBarData = snackBarUiState.snackBarQueue.peek() if (snackBarData != null) { @@ -699,67 +584,27 @@ private fun ContentScreen( } } } - } - } - - val pauseToggleButtonLambda = remember(captureController) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - PauseResumeToggleButton( - modifier = modifier, - onSetPause = captureController?.let { it::setPaused } ?: { _ -> }, - currentRecordingState = readyState.videoRecordingState - ) - } - } - } - - val imageWellLambda = remember(imageWellController, onNavigatePostCapture) { - @Composable { modifier: Modifier -> - val readyState = captureUiStateProvider() - if (readyState != null) { - if (readyState.externalCaptureMode == ExternalCaptureMode.Standard) { - (readyState.imageWellUiState as? ImageWellUiState.Content) - ?.let { contentState -> - ImageWell( - modifier = modifier, - imageWellUiState = contentState, - onClick = { - imageWellController?.imageWellToRepository( - contentState.mediaDescriptor - ) - onNavigatePostCapture() - } - ) + }, + pauseToggleButton = { + PauseResumeToggleButton( + onSetPause = captureController?.let { it::setPaused } ?: { _ -> }, + currentRecordingState = captureUiState.videoRecordingState + ) + }, + imageWell = { modifier -> + if (captureUiState.externalCaptureMode == ExternalCaptureMode.Standard) { + (captureUiState.imageWellUiState as? ImageWellUiState.Content)?.let { + ImageWell( + modifier = modifier, + imageWellUiState = it, + onClick = { + imageWellController?.imageWellToRepository(it.mediaDescriptor) + onNavigatePostCapture() } + ) } } } - } - - LayoutWrapper( - modifier = modifier, - hdrIndicator = hdrIndicatorLambda, - flashModeIndicator = flashModeIndicatorLambda, - videoQualityIndicator = videoQualityIndicatorLambda, - stabilizationIndicator = stabilizationIndicatorLambda, - - viewfinder = viewfinderLambda, - captureButton = captureButtonLambda, - flipCameraButton = flipCameraButtonLambda, - zoomLevelDisplay = zoomLevelDisplayLambda, - elapsedTimeDisplay = elapsedTimeDisplayLambda, - quickSettingsButton = quickSettingsButtonLambda, - audioToggleButton = audioToggleButtonLambda, - captureModeToggle = captureModeToggleLambda, - quickSettingsOverlay = quickSettingsOverlayLambda, - debugOverlay = debugOverlayLambda, - debugVisibilityWrapper = debugVisibilityWrapperLambda, - screenFlashOverlay = screenFlashOverlayLambda, - snackBar = snackBarLambda, - pauseToggleButton = pauseToggleButtonLambda, - imageWell = imageWellLambda ) } @@ -847,7 +692,8 @@ private fun LayoutWrapper( private fun ContentScreenPreview() { MaterialTheme { ContentScreen( - captureUiStateProvider = { FAKE_PREVIEW_UI_STATE_READY }, + captureUiState = FAKE_PREVIEW_UI_STATE_READY, + screenFlashUiState = ScreenFlashUiState(), surfaceRequest = null ) } @@ -858,7 +704,8 @@ private fun ContentScreenPreview() { private fun ContentScreen_Standard_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiStateProvider = { FAKE_PREVIEW_UI_STATE_READY.copy() }, + captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy(), + screenFlashUiState = ScreenFlashUiState(), surfaceRequest = null ) } @@ -869,11 +716,10 @@ private fun ContentScreen_Standard_Idle() { private fun ContentScreen_ImageOnly_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiStateProvider = { - FAKE_PREVIEW_UI_STATE_READY.copy( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) - ) - }, + captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + ), + screenFlashUiState = ScreenFlashUiState(), surfaceRequest = null ) } @@ -884,11 +730,10 @@ private fun ContentScreen_ImageOnly_Idle() { private fun ContentScreen_VideoOnly_Idle() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiStateProvider = { - FAKE_PREVIEW_UI_STATE_READY.copy( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY) - ) - }, + captureUiState = FAKE_PREVIEW_UI_STATE_READY.copy( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY) + ), + screenFlashUiState = ScreenFlashUiState(), surfaceRequest = null ) } @@ -899,7 +744,8 @@ private fun ContentScreen_VideoOnly_Idle() { private fun ContentScreen_Standard_Recording() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiStateProvider = { FAKE_PREVIEW_UI_STATE_PRESSED_RECORDING }, + captureUiState = FAKE_PREVIEW_UI_STATE_PRESSED_RECORDING, + screenFlashUiState = ScreenFlashUiState(), surfaceRequest = null ) } @@ -910,7 +756,8 @@ private fun ContentScreen_Standard_Recording() { private fun ContentScreen_Locked_Recording() { MaterialTheme(colorScheme = darkColorScheme()) { ContentScreen( - captureUiStateProvider = { FAKE_PREVIEW_UI_STATE_LOCKED_RECORDING }, + captureUiState = FAKE_PREVIEW_UI_STATE_LOCKED_RECORDING, + screenFlashUiState = ScreenFlashUiState(), surfaceRequest = null ) }