• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.google.jetpackcamera.feature.preview.ui
17 
18 import android.content.pm.ActivityInfo
19 import android.content.res.Configuration
20 import android.os.Build
21 import android.util.Log
22 import android.widget.Toast
23 import androidx.camera.compose.CameraXViewfinder
24 import androidx.camera.core.DynamicRange as CXDynamicRange
25 import androidx.camera.core.SurfaceRequest
26 import androidx.camera.viewfinder.compose.MutableCoordinateTransformer
27 import androidx.camera.viewfinder.core.ImplementationMode
28 import androidx.compose.animation.AnimatedVisibility
29 import androidx.compose.animation.animateColorAsState
30 import androidx.compose.animation.core.Animatable
31 import androidx.compose.animation.core.EaseOutExpo
32 import androidx.compose.animation.core.FastOutSlowInEasing
33 import androidx.compose.animation.core.LinearEasing
34 import androidx.compose.animation.core.Spring
35 import androidx.compose.animation.core.animateDpAsState
36 import androidx.compose.animation.core.animateFloatAsState
37 import androidx.compose.animation.core.spring
38 import androidx.compose.animation.core.tween
39 import androidx.compose.animation.fadeIn
40 import androidx.compose.animation.fadeOut
41 import androidx.compose.animation.scaleIn
42 import androidx.compose.foundation.Canvas
43 import androidx.compose.foundation.background
44 import androidx.compose.foundation.border
45 import androidx.compose.foundation.clickable
46 import androidx.compose.foundation.gestures.detectTapGestures
47 import androidx.compose.foundation.gestures.rememberTransformableState
48 import androidx.compose.foundation.gestures.transformable
49 import androidx.compose.foundation.layout.Arrangement
50 import androidx.compose.foundation.layout.Box
51 import androidx.compose.foundation.layout.BoxWithConstraints
52 import androidx.compose.foundation.layout.Column
53 import androidx.compose.foundation.layout.Row
54 import androidx.compose.foundation.layout.aspectRatio
55 import androidx.compose.foundation.layout.fillMaxHeight
56 import androidx.compose.foundation.layout.fillMaxSize
57 import androidx.compose.foundation.layout.height
58 import androidx.compose.foundation.layout.padding
59 import androidx.compose.foundation.layout.size
60 import androidx.compose.foundation.layout.width
61 import androidx.compose.foundation.shape.CircleShape
62 import androidx.compose.foundation.shape.RoundedCornerShape
63 import androidx.compose.material.icons.Icons
64 import androidx.compose.material.icons.filled.CameraAlt
65 import androidx.compose.material.icons.filled.FlipCameraAndroid
66 import androidx.compose.material.icons.filled.Mic
67 import androidx.compose.material.icons.filled.MicOff
68 import androidx.compose.material.icons.filled.Pause
69 import androidx.compose.material.icons.filled.PlayArrow
70 import androidx.compose.material.icons.filled.Settings
71 import androidx.compose.material.icons.filled.VideoStable
72 import androidx.compose.material.icons.filled.Videocam
73 import androidx.compose.material.icons.outlined.CameraAlt
74 import androidx.compose.material.icons.outlined.Videocam
75 import androidx.compose.material3.Icon
76 import androidx.compose.material3.IconButton
77 import androidx.compose.material3.LocalContentColor
78 import androidx.compose.material3.MaterialTheme
79 import androidx.compose.material3.SnackbarHostState
80 import androidx.compose.material3.SnackbarResult
81 import androidx.compose.material3.SuggestionChip
82 import androidx.compose.material3.Surface
83 import androidx.compose.material3.Text
84 import androidx.compose.runtime.Composable
85 import androidx.compose.runtime.CompositionLocalProvider
86 import androidx.compose.runtime.LaunchedEffect
87 import androidx.compose.runtime.getValue
88 import androidx.compose.runtime.mutableFloatStateOf
89 import androidx.compose.runtime.mutableStateOf
90 import androidx.compose.runtime.remember
91 import androidx.compose.runtime.rememberUpdatedState
92 import androidx.compose.runtime.setValue
93 import androidx.compose.runtime.snapshotFlow
94 import androidx.compose.ui.Alignment
95 import androidx.compose.ui.Modifier
96 import androidx.compose.ui.draw.alpha
97 import androidx.compose.ui.draw.clip
98 import androidx.compose.ui.draw.rotate
99 import androidx.compose.ui.draw.scale
100 import androidx.compose.ui.geometry.CornerRadius
101 import androidx.compose.ui.geometry.Offset
102 import androidx.compose.ui.geometry.Size
103 import androidx.compose.ui.graphics.Color
104 import androidx.compose.ui.graphics.painter.Painter
105 import androidx.compose.ui.graphics.vector.rememberVectorPainter
106 import androidx.compose.ui.input.pointer.pointerInput
107 import androidx.compose.ui.layout.layout
108 import androidx.compose.ui.platform.LocalContext
109 import androidx.compose.ui.platform.testTag
110 import androidx.compose.ui.res.painterResource
111 import androidx.compose.ui.res.stringResource
112 import androidx.compose.ui.semantics.Role
113 import androidx.compose.ui.semantics.semantics
114 import androidx.compose.ui.semantics.stateDescription
115 import androidx.compose.ui.text.style.TextAlign
116 import androidx.compose.ui.tooling.preview.Preview
117 import androidx.compose.ui.unit.Dp
118 import androidx.compose.ui.unit.dp
119 import com.google.jetpackcamera.core.camera.VideoRecordingState
120 import com.google.jetpackcamera.feature.preview.AudioUiState
121 import com.google.jetpackcamera.feature.preview.CaptureButtonUiState
122 import com.google.jetpackcamera.feature.preview.ElapsedTimeUiState
123 import com.google.jetpackcamera.feature.preview.PreviewUiState
124 import com.google.jetpackcamera.feature.preview.R
125 import com.google.jetpackcamera.feature.preview.StabilizationUiState
126 import com.google.jetpackcamera.feature.preview.ui.theme.PreviewPreviewTheme
127 import com.google.jetpackcamera.settings.model.AspectRatio
128 import com.google.jetpackcamera.settings.model.CaptureMode
129 import com.google.jetpackcamera.settings.model.LensFacing
130 import com.google.jetpackcamera.settings.model.StabilizationMode
131 import com.google.jetpackcamera.settings.model.VideoQuality
132 import kotlin.time.Duration.Companion.nanoseconds
133 import kotlinx.coroutines.delay
134 import kotlinx.coroutines.flow.combine
135 import kotlinx.coroutines.flow.distinctUntilChanged
136 import kotlinx.coroutines.flow.map
137 import kotlinx.coroutines.flow.onCompletion
138 
139 private const val TAG = "PreviewScreen"
140 private const val BLINK_TIME = 100L
141 
142 @Composable
143 fun ElapsedTimeText(modifier: Modifier = Modifier, elapsedTimeUiState: ElapsedTimeUiState.Enabled) {
144     Text(
145         modifier = modifier,
146         text = elapsedTimeUiState.elapsedTimeNanos.nanoseconds
147             .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) },
148         textAlign = TextAlign.Center
149     )
150 }
151 
152 @Composable
PauseResumeToggleButtonnull153 fun PauseResumeToggleButton(
154     modifier: Modifier = Modifier,
155     onSetPause: (Boolean) -> Unit,
156     size: Float = 55f,
157     currentRecordingState: VideoRecordingState.Active
158 ) {
159     var buttonClicked by remember { mutableStateOf(false) }
160     // animation value for the toggle icon itself
161     val animatedToggleScale by animateFloatAsState(
162         targetValue = if (buttonClicked) 1.1f else 1f, // Scale up to 110%
163         animationSpec = spring(
164             dampingRatio = Spring.DampingRatioLowBouncy,
165             stiffness = Spring.StiffnessMedium
166         ),
167         finishedListener = {
168             buttonClicked = false // Reset the trigger
169         }
170     )
171     Box(
172         modifier = modifier
173     ) {
174         Box(
175             modifier = Modifier
176                 .clickable(
177                     onClick = {
178                         buttonClicked = true
179                         onSetPause(currentRecordingState !is VideoRecordingState.Active.Paused)
180                     },
181                     indication = null,
182                     interactionSource = null
183                 )
184                 .size(size = size.dp)
185                 .scale(scale = animatedToggleScale)
186                 .clip(CircleShape)
187                 .background(Color.White),
188             contentAlignment = Alignment.Center
189         ) {
190             // icon
191             Icon(
192                 modifier = Modifier
193                     .align(Alignment.Center)
194                     .size((0.75 * size).dp),
195                 tint = Color.Red,
196                 imageVector = when (currentRecordingState) {
197                     is VideoRecordingState.Active.Recording -> Icons.Filled.Pause
198                     is VideoRecordingState.Active.Paused -> Icons.Filled.PlayArrow
199                 },
200                 contentDescription = "pause resume toggle"
201             )
202         }
203     }
204 }
205 
206 @Composable
AmplitudeVisualizernull207 fun AmplitudeVisualizer(
208     modifier: Modifier = Modifier,
209     size: Float = 75f,
210     audioUiState: AudioUiState,
211     onToggleAudio: () -> Unit
212 ) {
213     val currentUiState = rememberUpdatedState(audioUiState)
214     var buttonClicked by remember { mutableStateOf(false) }
215     // animation value for the toggle icon itself
216     val animatedToggleScale by animateFloatAsState(
217         targetValue = if (buttonClicked) 1.1f else 1f, // Scale up to 110%
218         animationSpec = spring(
219             dampingRatio = Spring.DampingRatioLowBouncy,
220             stiffness = Spring.StiffnessMedium
221         ),
222         finishedListener = {
223             buttonClicked = false // Reset the trigger
224         }
225     )
226 
227     // Tweak the multiplier to amplitude to adjust the visualizer sensitivity
228     val animatedAudioScale by animateFloatAsState(
229         targetValue = EaseOutExpo.transform(1 + (1.75f * currentUiState.value.amplitude.toFloat())),
230         label = "AudioAnimation"
231     )
232     Box(
233         modifier = modifier.clickable(
234             onClick = {
235                 buttonClicked = true
236                 onToggleAudio()
237             },
238             interactionSource = null,
239             // removes the greyish background animation that appears when clicking
240             indication = null
241         )
242     ) {
243         // animated audio circle
244         Canvas(
245             modifier = Modifier
246                 .align(Alignment.Center),
247             onDraw = {
248                 drawCircle(
249                     // tweak the multiplier to size to adjust the maximum size of the visualizer
250                     radius = (size * animatedAudioScale).coerceIn(size, size * 1.65f),
251                     alpha = .5f,
252                     color = Color.White
253                 )
254             }
255         )
256 
257         // static circle
258         Canvas(
259             modifier = Modifier
260                 .align(Alignment.Center),
261             onDraw = {
262                 drawCircle(
263                     radius = (size * animatedToggleScale),
264                     color = Color.White
265                 )
266             }
267         )
268 
269         Icon(
270             modifier = Modifier
271                 .align(Alignment.Center)
272                 .size((0.5 * size).dp)
273                 .scale(animatedToggleScale)
274                 .apply {
275                     if (currentUiState.value is AudioUiState.Enabled.On) {
276                         testTag(AMPLITUDE_HOT_TAG)
277                     } else {
278                         testTag(AMPLITUDE_NONE_TAG)
279                     }
280                 },
281             tint = Color.Black,
282             imageVector = if (currentUiState.value is AudioUiState.Enabled.On) {
283                 Icons.Filled.Mic
284             } else {
285                 Icons.Filled.MicOff
286             },
287             contentDescription = stringResource(id = R.string.audio_visualizer_icon)
288         )
289     }
290 }
291 
292 /**
293  * An invisible box that will display a [Toast] with specifications set by a [ToastMessage].
294  *
295  * @param toastMessage the specifications for the [Toast].
296  * @param onToastShown called once the Toast has been displayed.
297  *
298  */
299 @Composable
TestableToastnull300 fun TestableToast(
301     toastMessage: ToastMessage,
302     onToastShown: () -> Unit,
303     modifier: Modifier = Modifier
304 ) {
305     Box(
306         // box seems to need to have some size to be detected by UiAutomator
307         modifier = modifier
308             .size(20.dp)
309             .testTag(toastMessage.testTag)
310     ) {
311         val context = LocalContext.current
312         LaunchedEffect(toastMessage) {
313             if (toastMessage.shouldShowToast) {
314                 Toast.makeText(
315                     context,
316                     context.getText(toastMessage.stringResource),
317                     toastMessage.toastLength
318                 ).show()
319             }
320 
321             onToastShown()
322         }
323         Log.d(
324             TAG,
325             "Toast Displayed with message: ${stringResource(id = toastMessage.stringResource)}"
326         )
327     }
328 }
329 
330 @Composable
TestableSnackbarnull331 fun TestableSnackbar(
332     modifier: Modifier = Modifier,
333     snackbarToShow: SnackbarData,
334     snackbarHostState: SnackbarHostState,
335     onSnackbarResult: (String) -> Unit
336 ) {
337     Box(
338         // box seems to need to have some size to be detected by UiAutomator
339         modifier = modifier
340             .size(20.dp)
341             .testTag(snackbarToShow.testTag)
342     ) {
343         val context = LocalContext.current
344         LaunchedEffect(snackbarToShow) {
345             val message = context.getString(snackbarToShow.stringResource)
346             Log.d(TAG, "Snackbar Displayed with message: $message")
347             try {
348                 val result =
349                     snackbarHostState.showSnackbar(
350                         message = message,
351                         duration = snackbarToShow.duration,
352                         withDismissAction = snackbarToShow.withDismissAction,
353                         actionLabel = if (snackbarToShow.actionLabelRes == null) {
354                             null
355                         } else {
356                             context.getString(snackbarToShow.actionLabelRes)
357                         }
358                     )
359                 when (result) {
360                     SnackbarResult.ActionPerformed,
361                     SnackbarResult.Dismissed -> onSnackbarResult(snackbarToShow.cookie)
362                 }
363             } catch (e: Exception) {
364                 // This is equivalent to dismissing the snackbar
365                 onSnackbarResult(snackbarToShow.cookie)
366             }
367         }
368     }
369 }
370 
371 @Composable
DetectWindowColorModeChangesnull372 fun DetectWindowColorModeChanges(
373     surfaceRequest: SurfaceRequest,
374     implementationMode: ImplementationMode,
375     onRequestWindowColorMode: (Int) -> Unit
376 ) {
377     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
378         val currentSurfaceRequest: SurfaceRequest by rememberUpdatedState(surfaceRequest)
379         val currentImplementationMode: ImplementationMode by rememberUpdatedState(
380             implementationMode
381         )
382         val currentOnRequestWindowColorMode: (Int) -> Unit by rememberUpdatedState(
383             onRequestWindowColorMode
384         )
385 
386         LaunchedEffect(Unit) {
387             val colorModeSnapshotFlow =
388                 snapshotFlow { Pair(currentSurfaceRequest.dynamicRange, currentImplementationMode) }
389                     .map { (dynamicRange, implMode) ->
390                         val isSourceHdr = dynamicRange.encoding != CXDynamicRange.ENCODING_SDR
391                         val destSupportsHdr = implMode == ImplementationMode.EXTERNAL
392                         if (isSourceHdr && destSupportsHdr) {
393                             ActivityInfo.COLOR_MODE_HDR
394                         } else {
395                             ActivityInfo.COLOR_MODE_DEFAULT
396                         }
397                     }.distinctUntilChanged()
398 
399             val callbackSnapshotFlow = snapshotFlow { currentOnRequestWindowColorMode }
400 
401             // Combine both flows so that we call the callback every time it changes or the
402             // window color mode changes.
403             // We'll also reset to default when this LaunchedEffect is disposed
404             combine(colorModeSnapshotFlow, callbackSnapshotFlow) { colorMode, callback ->
405                 Pair(colorMode, callback)
406             }.onCompletion {
407                 currentOnRequestWindowColorMode(ActivityInfo.COLOR_MODE_DEFAULT)
408             }.collect { (colorMode, callback) ->
409                 callback(colorMode)
410             }
411         }
412     }
413 }
414 
415 /**
416  * this is the preview surface display. This view implements gestures tap to focus, pinch to zoom,
417  * and double-tap to flip camera
418  */
419 @Composable
PreviewDisplaynull420 fun PreviewDisplay(
421     previewUiState: PreviewUiState.Ready,
422     onTapToFocus: (x: Float, y: Float) -> Unit,
423     onFlipCamera: () -> Unit,
424     onZoomChange: (Float) -> Unit,
425     onRequestWindowColorMode: (Int) -> Unit,
426     aspectRatio: AspectRatio,
427     surfaceRequest: SurfaceRequest?,
428     modifier: Modifier = Modifier
429 ) {
430     val transformableState = rememberTransformableState(
431         onTransformation = { zoomChange, _, _ ->
432             onZoomChange(zoomChange)
433         }
434     )
435 
436     surfaceRequest?.let {
437         BoxWithConstraints(
438             modifier
439                 .testTag(PREVIEW_DISPLAY)
440                 .fillMaxSize()
441                 .background(Color.Black),
442             contentAlignment = Alignment.Center
443         ) {
444             val maxAspectRatio: Float = maxWidth / maxHeight
445             val aspectRatioFloat: Float = aspectRatio.ratio.toFloat()
446             val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat
447             val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat
448             val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat
449             var imageVisible by remember { mutableStateOf(true) }
450 
451             val imageAlpha: Float by animateFloatAsState(
452                 targetValue = if (imageVisible) 1f else 0f,
453                 animationSpec = tween(
454                     durationMillis = (BLINK_TIME / 2).toInt(),
455                     easing = LinearEasing
456                 ),
457                 label = ""
458             )
459 
460             LaunchedEffect(previewUiState.lastBlinkTimeStamp) {
461                 if (previewUiState.lastBlinkTimeStamp != 0L) {
462                     imageVisible = false
463                     delay(BLINK_TIME)
464                     imageVisible = true
465                 }
466             }
467 
468             Box(
469                 modifier = Modifier
470                     .width(width)
471                     .height(height)
472                     .transformable(state = transformableState)
473                     .alpha(imageAlpha)
474                     .clip(RoundedCornerShape(16.dp))
475             ) {
476                 val implementationMode = when {
477                     Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
478                     else -> ImplementationMode.EMBEDDED
479                 }
480 
481                 DetectWindowColorModeChanges(
482                     surfaceRequest = surfaceRequest,
483                     implementationMode = implementationMode,
484                     onRequestWindowColorMode = onRequestWindowColorMode
485                 )
486 
487                 val coordinateTransformer = remember { MutableCoordinateTransformer() }
488                 CameraXViewfinder(
489                     modifier = Modifier
490                         .fillMaxSize()
491                         .pointerInput(Unit) {
492                             detectTapGestures(
493                                 onDoubleTap = { offset ->
494                                     // double tap to flip camera
495                                     Log.d(TAG, "onDoubleTap $offset")
496                                     onFlipCamera()
497                                 },
498                                 onTap = {
499                                     with(coordinateTransformer) {
500                                         val surfaceCoords = it.transform()
501                                         Log.d(
502                                             "TAG",
503                                             "onTapToFocus: " +
504                                                 "input{$it} -> surface{$surfaceCoords}"
505                                         )
506                                         onTapToFocus(surfaceCoords.x, surfaceCoords.y)
507                                     }
508                                 }
509                             )
510                         },
511                     surfaceRequest = it,
512                     implementationMode = implementationMode,
513                     coordinateTransformer = coordinateTransformer
514                 )
515             }
516         }
517     }
518 }
519 
520 @Composable
StabilizationIconnull521 fun StabilizationIcon(
522     stabilizationUiState: StabilizationUiState.Enabled,
523     modifier: Modifier = Modifier
524 ) {
525     val contentColor = Color.White.let {
526         if (!stabilizationUiState.active) it.copy(alpha = 0.38f) else it
527     }
528     CompositionLocalProvider(LocalContentColor provides contentColor) {
529         if (stabilizationUiState.stabilizationMode != StabilizationMode.OFF) {
530             Icon(
531                 painter = when (stabilizationUiState) {
532                     is StabilizationUiState.Specific ->
533                         when (stabilizationUiState.stabilizationMode) {
534                             StabilizationMode.AUTO ->
535                                 throw IllegalStateException(
536                                     "AUTO is not a specific StabilizationUiState."
537                                 )
538 
539                             StabilizationMode.HIGH_QUALITY ->
540                                 painterResource(R.drawable.video_stable_hq_filled_icon)
541 
542                             StabilizationMode.OPTICAL ->
543                                 painterResource(R.drawable.video_stable_ois_filled_icon)
544 
545                             StabilizationMode.ON ->
546                                 rememberVectorPainter(Icons.Filled.VideoStable)
547 
548                             else ->
549                                 TODO(
550                                     "Cannot retrieve icon for unimplemented stabilization mode:" +
551                                         "${stabilizationUiState.stabilizationMode}"
552                                 )
553                         }
554 
555                     is StabilizationUiState.Auto -> {
556                         when (stabilizationUiState.stabilizationMode) {
557                             StabilizationMode.ON ->
558                                 painterResource(R.drawable.video_stable_auto_filled_icon)
559 
560                             StabilizationMode.OPTICAL ->
561                                 painterResource(R.drawable.video_stable_ois_auto_filled_icon)
562 
563                             else ->
564                                 TODO(
565                                     "Auto stabilization not yet implemented for " +
566                                         "${stabilizationUiState.stabilizationMode}, " +
567                                         "unable to retrieve icon."
568                                 )
569                         }
570                     }
571                 },
572                 contentDescription = when (stabilizationUiState.stabilizationMode) {
573                     StabilizationMode.AUTO ->
574                         stringResource(R.string.stabilization_icon_description_auto)
575 
576                     StabilizationMode.ON ->
577                         stringResource(R.string.stabilization_icon_description_preview_and_video)
578 
579                     StabilizationMode.HIGH_QUALITY ->
580                         stringResource(R.string.stabilization_icon_description_video_only)
581 
582                     StabilizationMode.OPTICAL ->
583                         stringResource(R.string.stabilization_icon_description_optical)
584 
585                     else -> null
586                 },
587                 modifier = modifier
588             )
589         }
590     }
591 }
592 
593 @Composable
VideoQualityIconnull594 fun VideoQualityIcon(videoQuality: VideoQuality, modifier: Modifier = Modifier) {
595     CompositionLocalProvider(LocalContentColor provides Color.White) {
596         if (videoQuality != VideoQuality.UNSPECIFIED) {
597             Icon(
598                 painter = when (videoQuality) {
599                     VideoQuality.SD ->
600                         painterResource(R.drawable.video_resolution_sd_icon)
601 
602                     VideoQuality.HD ->
603                         painterResource(R.drawable.video_resolution_hd_icon)
604 
605                     VideoQuality.FHD ->
606                         painterResource(R.drawable.video_resolution_fhd_icon)
607 
608                     VideoQuality.UHD ->
609                         painterResource(R.drawable.video_resolution_uhd_icon)
610 
611                     else ->
612                         throw IllegalStateException(
613                             "Illegal video quality state"
614                         )
615                 },
616                 contentDescription = when (videoQuality) {
617                     VideoQuality.SD ->
618                         stringResource(R.string.video_quality_description_sd)
619 
620                     VideoQuality.HD ->
621                         stringResource(R.string.video_quality_description_hd)
622 
623                     VideoQuality.FHD ->
624                         stringResource(R.string.video_quality_description_fhd)
625 
626                     VideoQuality.UHD ->
627                         stringResource(R.string.video_quality_description_uhd)
628 
629                     else -> null
630                 },
631                 modifier = modifier
632             )
633         }
634     }
635 }
636 
637 /**
638  * A temporary button that can be added to preview for quick testing purposes
639  */
640 @Composable
TestingButtonnull641 fun TestingButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
642     SuggestionChip(
643         onClick = { onClick() },
644         modifier = modifier,
645         label = {
646             Text(text = text)
647         }
648     )
649 }
650 
651 @Composable
FlipCameraButtonnull652 fun FlipCameraButton(
653     enabledCondition: Boolean,
654     lensFacing: LensFacing,
655     onClick: () -> Unit,
656     modifier: Modifier = Modifier
657 ) {
658     var rotation by remember { mutableFloatStateOf(0f) }
659     val animatedRotation = remember { Animatable(0f) }
660     var initialLaunch by remember { mutableStateOf(false) }
661 
662     // spin animate whenever lensfacing changes
663     LaunchedEffect(lensFacing) {
664         if (initialLaunch) {
665             // full 360
666             rotation -= 180f
667             animatedRotation.animateTo(
668                 targetValue = rotation,
669                 animationSpec = spring(
670                     dampingRatio = Spring.DampingRatioMediumBouncy,
671                     stiffness = Spring.StiffnessVeryLow
672                 )
673             )
674         }
675         // dont rotate on the initial launch
676         else {
677             initialLaunch = true
678         }
679     }
680     IconButton(
681         modifier = modifier.size(40.dp),
682         onClick = onClick,
683         enabled = enabledCondition
684     ) {
685         Icon(
686             imageVector = Icons.Filled.FlipCameraAndroid,
687             contentDescription = stringResource(id = R.string.flip_camera_content_description),
688             modifier = Modifier
689                 .size(72.dp)
690                 .rotate(animatedRotation.value)
691         )
692     }
693 }
694 
695 @Composable
SettingsNavButtonnull696 fun SettingsNavButton(onNavigateToSettings: () -> Unit, modifier: Modifier = Modifier) {
697     IconButton(
698         modifier = modifier,
699         onClick = onNavigateToSettings
700     ) {
701         Icon(
702             imageVector = Icons.Filled.Settings,
703             contentDescription = stringResource(R.string.settings_content_description),
704             modifier = Modifier.size(72.dp)
705         )
706     }
707 }
708 
709 @Composable
ZoomScaleTextnull710 fun ZoomScaleText(zoomScale: Float) {
711     val contentAlpha = animateFloatAsState(
712         targetValue = 10f,
713         label = "zoomScaleAlphaAnimation",
714         animationSpec = tween()
715     )
716     Text(
717         modifier = Modifier
718             .alpha(contentAlpha.value)
719             .testTag(ZOOM_RATIO_TAG),
720         text = stringResource(id = R.string.zoom_scale_text, zoomScale)
721     )
722 }
723 
724 @Composable
CurrentCameraIdTextnull725 fun CurrentCameraIdText(physicalCameraId: String?, logicalCameraId: String?) {
726     Column(horizontalAlignment = Alignment.CenterHorizontally) {
727         Row {
728             Text(text = stringResource(R.string.debug_text_logical_camera_id_prefix))
729             Text(
730                 modifier = Modifier.testTag(LOGICAL_CAMERA_ID_TAG),
731                 text = logicalCameraId ?: "---"
732             )
733         }
734         Row {
735             Text(text = stringResource(R.string.debug_text_physical_camera_id_prefix))
736             Text(
737                 modifier = Modifier.testTag(PHYSICAL_CAMERA_ID_TAG),
738                 text = physicalCameraId ?: "---"
739             )
740         }
741     }
742 }
743 
744 @Composable
CaptureButtonnull745 fun CaptureButton(
746     modifier: Modifier = Modifier,
747     onCaptureImage: () -> Unit,
748     onStartVideoRecording: () -> Unit,
749     onStopVideoRecording: () -> Unit,
750     onLockVideoRecording: (Boolean) -> Unit,
751     captureButtonUiState: CaptureButtonUiState,
752     captureButtonSize: Float = 80f
753 ) {
754     val currentUiState = rememberUpdatedState(captureButtonUiState)
755     var isPressedDown by remember {
756         mutableStateOf(false)
757     }
758     var isLongPressing by remember {
759         mutableStateOf(false)
760     }
761 
762     val currentColor = LocalContentColor.current
763     Box(
764         contentAlignment = Alignment.Center,
765         modifier = modifier
766             .pointerInput(Unit) {
767                 detectTapGestures(
768                     onLongPress = {
769                         isLongPressing = true
770                         val uiState = currentUiState.value
771                         if (uiState is CaptureButtonUiState.Enabled.Idle) {
772                             when (uiState.captureMode) {
773                                 CaptureMode.STANDARD,
774                                 CaptureMode.VIDEO_ONLY -> {
775                                     onStartVideoRecording()
776                                 }
777 
778                                 CaptureMode.IMAGE_ONLY -> {}
779                             }
780                         }
781                     },
782                     onPress = {
783                         isPressedDown = true
784                         awaitRelease()
785                         isPressedDown = false
786                         isLongPressing = false
787                         val uiState = currentUiState.value
788                         when (uiState) {
789                             // stop recording after button is lifted
790                             is CaptureButtonUiState.Enabled.Recording.PressedRecording -> {
791                                 onStopVideoRecording()
792                             }
793 
794                             is CaptureButtonUiState.Enabled.Idle,
795                             CaptureButtonUiState.Unavailable -> {
796                             }
797 
798                             CaptureButtonUiState.Enabled.Recording.LockedRecording -> {}
799                         }
800                     },
801                     onTap = {
802                         val uiState = currentUiState.value
803                         when (uiState) {
804                             is CaptureButtonUiState.Enabled.Idle -> {
805                                 if (!isLongPressing) {
806                                     when (uiState.captureMode) {
807                                         CaptureMode.STANDARD,
808                                         CaptureMode.IMAGE_ONLY -> onCaptureImage()
809 
810                                         CaptureMode.VIDEO_ONLY -> {
811                                             onLockVideoRecording(true)
812                                             onStartVideoRecording()
813                                         }
814                                     }
815                                 }
816                             }
817                             // stop if locked recording
818                             CaptureButtonUiState.Enabled.Recording.LockedRecording -> {
819                                 onStopVideoRecording()
820                             }
821 
822                             CaptureButtonUiState.Unavailable,
823                             CaptureButtonUiState.Enabled.Recording.PressedRecording -> {
824                             }
825                         }
826                     }
827                 )
828             }
829             .size(captureButtonSize.dp)
830             .border(4.dp, currentColor, CircleShape) // border is the white ring
831     ) {
832         // now we draw center circle
833         val centerShapeSize by animateDpAsState(
834             targetValue = when (val uiState = currentUiState.value) {
835                 // inner circle fills white ring when locked
836                 CaptureButtonUiState.Enabled.Recording.LockedRecording -> captureButtonSize.dp
837                 // larger circle while recording, but not max size
838                 CaptureButtonUiState.Enabled.Recording.PressedRecording ->
839                     (captureButtonSize * .7f).dp
840 
841                 CaptureButtonUiState.Unavailable -> 0.dp
842                 is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) {
843                     // no inner circle will be visible on STANDARD
844                     CaptureMode.STANDARD -> 0.dp
845                     // large white circle will be visible on IMAGE_ONLY
846                     CaptureMode.IMAGE_ONLY -> (captureButtonSize * .7f).dp
847                     // small red circle will be visible on VIDEO_ONLY
848                     CaptureMode.VIDEO_ONLY -> (captureButtonSize * .35f).dp
849                 }
850             },
851             animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
852         )
853 
854         // used to fade between red/white in the center of the capture button
855         val animatedColor by animateColorAsState(
856             targetValue = when (val uiState = currentUiState.value) {
857                 is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) {
858                     CaptureMode.STANDARD -> Color.White
859                     CaptureMode.IMAGE_ONLY -> Color.White
860                     CaptureMode.VIDEO_ONLY -> Color.Red
861                 }
862 
863                 is CaptureButtonUiState.Enabled.Recording -> Color.Red
864                 is CaptureButtonUiState.Unavailable -> Color.Transparent
865             },
866             animationSpec = tween(durationMillis = 500)
867         )
868         // inner circle
869         Box(
870             contentAlignment = Alignment.Center,
871             modifier = Modifier
872                 .size(centerShapeSize)
873                 .clip(CircleShape)
874                 .alpha(
875                     if (isPressedDown &&
876                         currentUiState.value ==
877                         CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY)
878                     ) {
879                         .5f // transparency to indicate click ONLY on IMAGE_ONLY
880                     } else {
881                         1f // solid alpha the rest of the time
882                     }
883                 )
884                 .background(animatedColor)
885         ) {}
886         // central "square" stop icon
887         AnimatedVisibility(
888             visible = currentUiState.value is
889                 CaptureButtonUiState.Enabled.Recording.LockedRecording,
890             enter = scaleIn(initialScale = .5f) + fadeIn(),
891             exit = fadeOut()
892         ) {
893             val smallBoxSize = (captureButtonSize / 5f).dp
894             Canvas(modifier = Modifier) {
895                 drawRoundRect(
896                     color = Color.White,
897                     topLeft = Offset(-smallBoxSize.toPx() / 2f, -smallBoxSize.toPx() / 2f),
898                     size = Size(smallBoxSize.toPx(), smallBoxSize.toPx()),
899                     cornerRadius = CornerRadius(smallBoxSize.toPx() * .15f)
900                 )
901             }
902         }
903     }
904 }
905 
906 enum class ToggleState {
907     Left,
908     Right
909 }
910 
911 @Composable
ToggleButtonnull912 fun ToggleButton(
913     leftIcon: Painter,
914     rightIcon: Painter,
915     modifier: Modifier = Modifier,
916     initialState: ToggleState = ToggleState.Left,
917     onToggleStateChanged: (newState: ToggleState) -> Unit = {},
<lambda>null918     onToggleWhenDisabled: () -> Unit = {},
919     enabled: Boolean = true,
920     leftIconDescription: String = "leftIcon",
921     rightIconDescription: String = "rightIcon",
922     iconPadding: Dp = 8.dp
923 ) {
924     val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest
925     val disableColor = MaterialTheme.colorScheme.onSurface
926     val iconSelectionColor = MaterialTheme.colorScheme.onPrimary
927     val iconUnSelectionColor = MaterialTheme.colorScheme.primary
928     val circleSelectionColor = MaterialTheme.colorScheme.primary
929     val circleColor = if (enabled) circleSelectionColor else disableColor.copy(alpha = 0.12f)
<lambda>null930     var toggleState by remember { mutableStateOf(initialState) }
931     val animatedTogglePosition by animateFloatAsState(
932         when (toggleState) {
933             ToggleState.Left -> 0f
934             ToggleState.Right -> 1f
935         },
936         label = "togglePosition"
937     )
938 
939     Surface(
940         modifier = modifier
941             .clip(shape = RoundedCornerShape(50))
942             .then(
943                 Modifier.clickable(
944                     role = Role.Switch
<lambda>null945                 ) {
946                     if (enabled) {
947                         toggleState = when (toggleState) {
948                             ToggleState.Left -> ToggleState.Right
949                             ToggleState.Right -> ToggleState.Left
950                         }
951                         onToggleStateChanged(toggleState)
952                     } else {
953                         onToggleWhenDisabled()
954                     }
955                 }
956             )
<lambda>null957             .semantics {
958                 stateDescription = when (toggleState) {
959                     ToggleState.Left -> leftIconDescription
960                     ToggleState.Right -> rightIconDescription
961                 }
962             }
963             .width(64.dp)
964             .height(32.dp),
965         color = backgroundColor
<lambda>null966     ) {
967         Box {
968             Row(
969                 modifier = Modifier.matchParentSize(),
970                 verticalAlignment = Alignment.CenterVertically
971             ) {
972                 Box(
973                     Modifier
974                         .layout { measurable, constraints ->
975                             val placeable = measurable.measure(constraints)
976                             layout(placeable.width, placeable.height) {
977                                 val xPos = animatedTogglePosition *
978                                     (constraints.maxWidth - placeable.width)
979                                 placeable.placeRelative(xPos.toInt(), 0)
980                             }
981                         }
982                         .fillMaxHeight()
983                         .aspectRatio(1f)
984                         .clip(RoundedCornerShape(50))
985                         .background(circleColor)
986                 )
987             }
988             Row(
989                 modifier = Modifier
990                     .matchParentSize()
991                     .then(
992                         if (enabled) Modifier else Modifier.alpha(0.38f)
993                     ),
994                 verticalAlignment = Alignment.CenterVertically,
995                 horizontalArrangement = Arrangement.SpaceBetween
996             ) {
997                 Icon(
998                     painter = leftIcon,
999                     contentDescription = leftIconDescription,
1000                     modifier = Modifier.padding(iconPadding),
1001                     tint = if (!enabled) {
1002                         disableColor
1003                     } else if (toggleState == ToggleState.Left) {
1004                         iconSelectionColor
1005                     } else {
1006                         iconUnSelectionColor
1007                     }
1008                 )
1009                 Icon(
1010                     painter = rightIcon,
1011                     contentDescription = rightIconDescription,
1012                     modifier = Modifier.padding(iconPadding),
1013                     tint = if (!enabled) {
1014                         disableColor
1015                     } else if (toggleState == ToggleState.Right) {
1016                         iconSelectionColor
1017                     } else {
1018                         iconUnSelectionColor
1019                     }
1020                 )
1021             }
1022         }
1023     }
1024 }
1025 
1026 @Preview(name = "Light Mode")
1027 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
1028 @Composable
Preview_ToggleButton_Selecting_Leftnull1029 private fun Preview_ToggleButton_Selecting_Left() {
1030     val initialState = ToggleState.Left
1031     var toggleState by remember {
1032         mutableStateOf(initialState)
1033     }
1034     PreviewPreviewTheme(dynamicColor = false) {
1035         ToggleButton(
1036             leftIcon = if (toggleState == ToggleState.Left) {
1037                 rememberVectorPainter(image = Icons.Filled.CameraAlt)
1038             } else {
1039                 rememberVectorPainter(image = Icons.Outlined.CameraAlt)
1040             },
1041             rightIcon = if (toggleState == ToggleState.Right) {
1042                 rememberVectorPainter(image = Icons.Filled.Videocam)
1043             } else {
1044                 rememberVectorPainter(image = Icons.Outlined.Videocam)
1045             },
1046             initialState = ToggleState.Left,
1047             onToggleStateChanged = {
1048                 toggleState = it
1049             }
1050         )
1051     }
1052 }
1053 
1054 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
1055 @Composable
Preview_ToggleButton_Selecting_Rightnull1056 private fun Preview_ToggleButton_Selecting_Right() {
1057     PreviewPreviewTheme(dynamicColor = false) {
1058         ToggleButton(
1059             leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
1060             rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
1061             initialState = ToggleState.Right
1062         )
1063     }
1064 }
1065 
1066 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
1067 @Composable
Preview_ToggleButton_Disablednull1068 private fun Preview_ToggleButton_Disabled() {
1069     PreviewPreviewTheme(dynamicColor = false) {
1070         ToggleButton(
1071             leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
1072             rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
1073             initialState = ToggleState.Right,
1074             enabled = false
1075         )
1076     }
1077 }
1078