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.res.Configuration
19 import android.os.Build
20 import android.util.Log
21 import android.widget.Toast
22 import androidx.camera.core.SurfaceRequest
23 import androidx.camera.viewfinder.surface.ImplementationMode
24 import androidx.compose.animation.core.EaseOutExpo
25 import androidx.compose.animation.core.LinearEasing
26 import androidx.compose.animation.core.animateFloatAsState
27 import androidx.compose.animation.core.tween
28 import androidx.compose.foundation.Canvas
29 import androidx.compose.foundation.background
30 import androidx.compose.foundation.border
31 import androidx.compose.foundation.clickable
32 import androidx.compose.foundation.gestures.detectTapGestures
33 import androidx.compose.foundation.gestures.rememberTransformableState
34 import androidx.compose.foundation.gestures.transformable
35 import androidx.compose.foundation.layout.Arrangement
36 import androidx.compose.foundation.layout.Box
37 import androidx.compose.foundation.layout.BoxWithConstraints
38 import androidx.compose.foundation.layout.Column
39 import androidx.compose.foundation.layout.Row
40 import androidx.compose.foundation.layout.aspectRatio
41 import androidx.compose.foundation.layout.fillMaxHeight
42 import androidx.compose.foundation.layout.fillMaxSize
43 import androidx.compose.foundation.layout.height
44 import androidx.compose.foundation.layout.padding
45 import androidx.compose.foundation.layout.size
46 import androidx.compose.foundation.layout.width
47 import androidx.compose.foundation.shape.CircleShape
48 import androidx.compose.foundation.shape.RoundedCornerShape
49 import androidx.compose.material.icons.Icons
50 import androidx.compose.material.icons.filled.CameraAlt
51 import androidx.compose.material.icons.filled.FlipCameraAndroid
52 import androidx.compose.material.icons.filled.Mic
53 import androidx.compose.material.icons.filled.MicOff
54 import androidx.compose.material.icons.filled.Nightlight
55 import androidx.compose.material.icons.filled.Settings
56 import androidx.compose.material.icons.filled.VideoStable
57 import androidx.compose.material.icons.filled.Videocam
58 import androidx.compose.material.icons.outlined.CameraAlt
59 import androidx.compose.material.icons.outlined.Nightlight
60 import androidx.compose.material.icons.outlined.Videocam
61 import androidx.compose.material3.Icon
62 import androidx.compose.material3.IconButton
63 import androidx.compose.material3.LocalContentColor
64 import androidx.compose.material3.MaterialTheme
65 import androidx.compose.material3.SnackbarHostState
66 import androidx.compose.material3.SnackbarResult
67 import androidx.compose.material3.SuggestionChip
68 import androidx.compose.material3.Surface
69 import androidx.compose.material3.Text
70 import androidx.compose.runtime.Composable
71 import androidx.compose.runtime.LaunchedEffect
72 import androidx.compose.runtime.getValue
73 import androidx.compose.runtime.mutableStateOf
74 import androidx.compose.runtime.remember
75 import androidx.compose.runtime.rememberCoroutineScope
76 import androidx.compose.runtime.rememberUpdatedState
77 import androidx.compose.runtime.setValue
78 import androidx.compose.ui.Alignment
79 import androidx.compose.ui.Modifier
80 import androidx.compose.ui.draw.alpha
81 import androidx.compose.ui.draw.clip
82 import androidx.compose.ui.graphics.Color
83 import androidx.compose.ui.graphics.painter.Painter
84 import androidx.compose.ui.graphics.vector.rememberVectorPainter
85 import androidx.compose.ui.input.pointer.pointerInput
86 import androidx.compose.ui.layout.layout
87 import androidx.compose.ui.platform.LocalContext
88 import androidx.compose.ui.platform.testTag
89 import androidx.compose.ui.res.stringResource
90 import androidx.compose.ui.tooling.preview.Preview
91 import androidx.compose.ui.unit.Dp
92 import androidx.compose.ui.unit.dp
93 import com.google.jetpackcamera.feature.preview.PreviewUiState
94 import com.google.jetpackcamera.feature.preview.R
95 import com.google.jetpackcamera.feature.preview.VideoRecordingState
96 import com.google.jetpackcamera.feature.preview.ui.theme.PreviewPreviewTheme
97 import com.google.jetpackcamera.settings.model.AspectRatio
98 import com.google.jetpackcamera.settings.model.LowLightBoost
99 import com.google.jetpackcamera.settings.model.Stabilization
100 import kotlinx.coroutines.delay
101 import kotlinx.coroutines.launch
102 
103 private const val TAG = "PreviewScreen"
104 private const val BLINK_TIME = 100L
105 
106 @Composable
107 fun AmplitudeVisualizer(
108     modifier: Modifier = Modifier,
109     size: Int = 100,
110     audioAmplitude: Double,
111     onToggleMute: () -> Unit
112 ) {
113     // Tweak the multiplier to amplitude to adjust the visualizer sensitivity
114     val animatedScaling by animateFloatAsState(
115         targetValue = EaseOutExpo.transform(1 + (1.75f * audioAmplitude.toFloat())),
116         label = "AudioAnimation"
117     )
118     Box(modifier = modifier.clickable { onToggleMute() }) {
119         // animated circle
120         Canvas(
121             modifier = Modifier
122                 .align(Alignment.Center),
123             onDraw = {
124                 drawCircle(
125                     // tweak the multiplier to size to adjust the maximum size of the visualizer
126                     radius = (size * animatedScaling).coerceIn(size.toFloat(), size * 1.65f),
127                     alpha = .5f,
128                     color = Color.White
129                 )
130             }
131         )
132 
133         // static circle
134         Canvas(
135             modifier = Modifier
136                 .align(Alignment.Center),
137             onDraw = {
138                 drawCircle(
139                     radius = (size.toFloat()),
140                     color = Color.White
141                 )
142             }
143         )
144 
145         Icon(
146             modifier = Modifier
147                 .align(Alignment.Center)
148                 .size((0.5 * size).dp)
149                 .apply {
150                     if (audioAmplitude != 0.0) {
151                         testTag(AMPLITUDE_HOT_TAG)
152                     } else {
153                         testTag(AMPLITUDE_NONE_TAG)
154                     }
155                 },
156             tint = Color.Black,
157             imageVector = if (audioAmplitude != 0.0) {
158                 Icons.Filled.Mic
159             } else {
160                 Icons.Filled.MicOff
161             },
162             contentDescription = stringResource(id = R.string.audio_visualizer_icon)
163         )
164     }
165 }
166 
167 /**
168  * An invisible box that will display a [Toast] with specifications set by a [ToastMessage].
169  *
170  * @param toastMessage the specifications for the [Toast].
171  * @param onToastShown called once the Toast has been displayed.
172  *
173  */
174 @Composable
TestableToastnull175 fun TestableToast(
176     toastMessage: ToastMessage,
177     onToastShown: () -> Unit,
178     modifier: Modifier = Modifier
179 ) {
180     Box(
181         // box seems to need to have some size to be detected by UiAutomator
182         modifier = modifier
183             .size(20.dp)
184             .testTag(toastMessage.testTag)
185     ) {
186         val context = LocalContext.current
187         LaunchedEffect(toastMessage) {
188             if (toastMessage.shouldShowToast) {
189                 Toast.makeText(
190                     context,
191                     context.getText(toastMessage.stringResource),
192                     toastMessage.toastLength
193                 ).show()
194             }
195 
196             onToastShown()
197         }
198         Log.d(
199             TAG,
200             "Toast Displayed with message: ${stringResource(id = toastMessage.stringResource)}"
201         )
202     }
203 }
204 
205 @Composable
TestableSnackbarnull206 fun TestableSnackbar(
207     modifier: Modifier = Modifier,
208     snackbarToShow: SnackbarData,
209     snackbarHostState: SnackbarHostState,
210     onSnackbarResult: (String) -> Unit
211 ) {
212     Box(
213         // box seems to need to have some size to be detected by UiAutomator
214         modifier = modifier
215             .size(20.dp)
216             .testTag(snackbarToShow.testTag)
217     ) {
218         val context = LocalContext.current
219         LaunchedEffect(snackbarToShow) {
220             val message = context.getString(snackbarToShow.stringResource)
221             Log.d(TAG, "Snackbar Displayed with message: $message")
222             try {
223                 val result =
224                     snackbarHostState.showSnackbar(
225                         message = message,
226                         duration = snackbarToShow.duration,
227                         withDismissAction = snackbarToShow.withDismissAction,
228                         actionLabel = if (snackbarToShow.actionLabelRes == null) {
229                             null
230                         } else {
231                             context.getString(snackbarToShow.actionLabelRes)
232                         }
233                     )
234                 when (result) {
235                     SnackbarResult.ActionPerformed,
236                     SnackbarResult.Dismissed -> onSnackbarResult(snackbarToShow.cookie)
237                 }
238             } catch (e: Exception) {
239                 // This is equivalent to dismissing the snackbar
240                 onSnackbarResult(snackbarToShow.cookie)
241             }
242         }
243     }
244 }
245 
246 /**
247  * this is the preview surface display. This view implements gestures tap to focus, pinch to zoom,
248  * and double-tap to flip camera
249  */
250 @Composable
PreviewDisplaynull251 fun PreviewDisplay(
252     previewUiState: PreviewUiState.Ready,
253     onTapToFocus: (x: Float, y: Float) -> Unit,
254     onFlipCamera: () -> Unit,
255     onZoomChange: (Float) -> Unit,
256     onRequestWindowColorMode: (Int) -> Unit,
257     aspectRatio: AspectRatio,
258     surfaceRequest: SurfaceRequest?,
259     modifier: Modifier = Modifier
260 ) {
261     val transformableState = rememberTransformableState(
262         onTransformation = { zoomChange, _, _ ->
263             onZoomChange(zoomChange)
264         }
265     )
266 
267     val currentOnFlipCamera by rememberUpdatedState(onFlipCamera)
268 
269     surfaceRequest?.let {
270         BoxWithConstraints(
271             Modifier
272                 .testTag(PREVIEW_DISPLAY)
273                 .fillMaxSize()
274                 .background(Color.Black)
275                 .pointerInput(Unit) {
276                     detectTapGestures(
277                         onDoubleTap = { offset ->
278                             // double tap to flip camera
279                             Log.d(TAG, "onDoubleTap $offset")
280                             currentOnFlipCamera()
281                         }
282                     )
283                 },
284 
285             contentAlignment = Alignment.Center
286         ) {
287             val maxAspectRatio: Float = maxWidth / maxHeight
288             val aspectRatioFloat: Float = aspectRatio.ratio.toFloat()
289             val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat
290             val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat
291             val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat
292             var imageVisible by remember { mutableStateOf(true) }
293 
294             val imageAlpha: Float by animateFloatAsState(
295                 targetValue = if (imageVisible) 1f else 0f,
296                 animationSpec = tween(
297                     durationMillis = (BLINK_TIME / 2).toInt(),
298                     easing = LinearEasing
299                 ),
300                 label = ""
301             )
302 
303             LaunchedEffect(previewUiState.lastBlinkTimeStamp) {
304                 if (previewUiState.lastBlinkTimeStamp != 0L) {
305                     imageVisible = false
306                     delay(BLINK_TIME)
307                     imageVisible = true
308                 }
309             }
310 
311             Box(
312                 modifier = Modifier
313                     .width(width)
314                     .height(height)
315                     .transformable(state = transformableState)
316                     .alpha(imageAlpha)
317                     .clip(RoundedCornerShape(16.dp))
318             ) {
319                 CameraXViewfinder(
320                     modifier = Modifier.fillMaxSize(),
321                     surfaceRequest = it,
322                     implementationMode = when {
323                         Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
324                         else -> ImplementationMode.EMBEDDED
325                     },
326                     onRequestWindowColorMode = onRequestWindowColorMode,
327                     onTap = { x, y -> onTapToFocus(x, y) }
328                 )
329             }
330         }
331     }
332 }
333 
334 @Composable
StabilizationIconnull335 fun StabilizationIcon(
336     videoStabilization: Stabilization,
337     previewStabilization: Stabilization,
338     modifier: Modifier = Modifier
339 ) {
340     if (videoStabilization == Stabilization.ON || previewStabilization == Stabilization.ON) {
341         val descriptionText = if (videoStabilization == Stabilization.ON) {
342             stringResource(id = R.string.stabilization_icon_description_preview_and_video)
343         } else {
344             // previewStabilization will not be on for high quality
345             stringResource(id = R.string.stabilization_icon_description_video_only)
346         }
347         Icon(
348             imageVector = Icons.Filled.VideoStable,
349             contentDescription = descriptionText,
350             modifier = modifier
351         )
352     }
353 }
354 
355 /**
356  * LowLightBoostIcon has 3 states
357  * - disabled: hidden
358  * - enabled and inactive: outline
359  * - enabled and active: filled
360  */
361 @Composable
LowLightBoostIconnull362 fun LowLightBoostIcon(lowLightBoost: LowLightBoost, modifier: Modifier = Modifier) {
363     when (lowLightBoost) {
364         LowLightBoost.ENABLED -> {
365             Icon(
366                 imageVector = Icons.Outlined.Nightlight,
367                 contentDescription =
368                 stringResource(id = R.string.quick_settings_lowlightboost_enabled),
369                 modifier = modifier.alpha(0.5f)
370             )
371         }
372         LowLightBoost.DISABLED -> {
373         }
374     }
375 }
376 
377 /**
378  * A temporary button that can be added to preview for quick testing purposes
379  */
380 @Composable
TestingButtonnull381 fun TestingButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
382     SuggestionChip(
383         onClick = { onClick() },
384         modifier = modifier,
385         label = {
386             Text(text = text)
387         }
388     )
389 }
390 
391 @Composable
FlipCameraButtonnull392 fun FlipCameraButton(
393     enabledCondition: Boolean,
394     onClick: () -> Unit,
395     modifier: Modifier = Modifier
396 ) {
397     IconButton(
398         modifier = modifier.size(40.dp),
399         onClick = onClick,
400         enabled = enabledCondition
401     ) {
402         Icon(
403             imageVector = Icons.Filled.FlipCameraAndroid,
404             contentDescription = stringResource(id = R.string.flip_camera_content_description),
405             modifier = Modifier.size(72.dp)
406         )
407     }
408 }
409 
410 @Composable
SettingsNavButtonnull411 fun SettingsNavButton(onNavigateToSettings: () -> Unit, modifier: Modifier = Modifier) {
412     IconButton(
413         modifier = modifier,
414         onClick = onNavigateToSettings
415     ) {
416         Icon(
417             imageVector = Icons.Filled.Settings,
418             contentDescription = stringResource(R.string.settings_content_description),
419             modifier = Modifier.size(72.dp)
420         )
421     }
422 }
423 
424 @Composable
ZoomScaleTextnull425 fun ZoomScaleText(zoomScale: Float) {
426     val contentAlpha = animateFloatAsState(
427         targetValue = 10f,
428         label = "zoomScaleAlphaAnimation",
429         animationSpec = tween()
430     )
431     Text(
432         modifier = Modifier
433             .alpha(contentAlpha.value)
434             .testTag(ZOOM_RATIO_TAG),
435         text = stringResource(id = R.string.zoom_scale_text, zoomScale)
436     )
437 }
438 
439 @Composable
CurrentCameraIdTextnull440 fun CurrentCameraIdText(physicalCameraId: String?, logicalCameraId: String?) {
441     Column(horizontalAlignment = Alignment.CenterHorizontally) {
442         Row {
443             Text(text = stringResource(R.string.debug_text_logical_camera_id_prefix))
444             Text(
445                 modifier = Modifier.testTag(LOGICAL_CAMERA_ID_TAG),
446                 text = logicalCameraId ?: "---"
447             )
448         }
449         Row {
450             Text(text = stringResource(R.string.debug_text_physical_camera_id_prefix))
451             Text(
452                 modifier = Modifier.testTag(PHYSICAL_CAMERA_ID_TAG),
453                 text = physicalCameraId ?: "---"
454             )
455         }
456     }
457 }
458 
459 @Composable
CaptureButtonnull460 fun CaptureButton(
461     onClick: () -> Unit,
462     onLongPress: () -> Unit,
463     onRelease: () -> Unit,
464     videoRecordingState: VideoRecordingState,
465     modifier: Modifier = Modifier
466 ) {
467     var isPressedDown by remember {
468         mutableStateOf(false)
469     }
470     val currentColor = LocalContentColor.current
471     Box(
472         modifier = modifier
473             .pointerInput(Unit) {
474                 detectTapGestures(
475                     onLongPress = {
476                         onLongPress()
477                     },
478                     // TODO: @kimblebee - stopVideoRecording is being called every time the capture
479                     // button is pressed -- regardless of tap or long press
480                     onPress = {
481                         isPressedDown = true
482                         awaitRelease()
483                         isPressedDown = false
484                         onRelease()
485                     },
486                     onTap = { onClick() }
487                 )
488             }
489             .size(120.dp)
490             .padding(18.dp)
491             .border(4.dp, currentColor, CircleShape)
492     ) {
493         Canvas(modifier = Modifier.size(110.dp), onDraw = {
494             drawCircle(
495                 color =
496                 when (videoRecordingState) {
497                     VideoRecordingState.INACTIVE -> {
498                         if (isPressedDown) currentColor else Color.Transparent
499                     }
500 
501                     VideoRecordingState.ACTIVE -> Color.Red
502                 }
503             )
504         })
505     }
506 }
507 
508 enum class ToggleState {
509     Left,
510     Right
511 }
512 
513 @Composable
ToggleButtonnull514 fun ToggleButton(
515     leftIcon: Painter,
516     rightIcon: Painter,
517     modifier: Modifier = Modifier
518         .width(64.dp)
519         .height(32.dp),
520     initialState: ToggleState = ToggleState.Left,
521     onToggleStateChanged: (newState: ToggleState) -> Unit = {},
<lambda>null522     onToggleWhenDisabled: () -> Unit = {},
523     enabled: Boolean = true,
524     leftIconDescription: String = "leftIcon",
525     rightIconDescription: String = "rightIcon",
526     iconPadding: Dp = 8.dp
527 ) {
528     val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest
529     val disableColor = MaterialTheme.colorScheme.onSurface
530     val iconSelectionColor = MaterialTheme.colorScheme.onPrimary
531     val iconUnSelectionColor = MaterialTheme.colorScheme.primary
532     val circleSelectionColor = MaterialTheme.colorScheme.primary
533     val circleColor = if (enabled) circleSelectionColor else disableColor.copy(alpha = 0.12f)
<lambda>null534     var toggleState by remember { mutableStateOf(initialState) }
535     val animatedTogglePosition by animateFloatAsState(
536         when (toggleState) {
537             ToggleState.Left -> 0f
538             ToggleState.Right -> 1f
539         },
540         label = "togglePosition"
541     )
542     val scope = rememberCoroutineScope()
543 
544     Surface(
545         modifier = modifier
546             .clip(shape = RoundedCornerShape(50))
547             .then(
<lambda>null548                 Modifier.clickable {
549                     scope.launch {
550                         if (enabled) {
551                             toggleState = when (toggleState) {
552                                 ToggleState.Left -> ToggleState.Right
553                                 ToggleState.Right -> ToggleState.Left
554                             }
555                             onToggleStateChanged(toggleState)
556                         } else {
557                             onToggleWhenDisabled()
558                         }
559                     }
560                 }
561             ),
562         color = backgroundColor
<lambda>null563     ) {
564         Box {
565             Row(
566                 modifier = Modifier.matchParentSize(),
567                 verticalAlignment = Alignment.CenterVertically
568             ) {
569                 Box(
570                     Modifier
571                         .layout { measurable, constraints ->
572                             val placeable = measurable.measure(constraints)
573                             layout(placeable.width, placeable.height) {
574                                 val xPos = animatedTogglePosition *
575                                     (constraints.maxWidth - placeable.width)
576                                 placeable.placeRelative(xPos.toInt(), 0)
577                             }
578                         }
579                         .fillMaxHeight()
580                         .aspectRatio(1f)
581                         .clip(RoundedCornerShape(50))
582                         .background(circleColor)
583                 )
584             }
585             Row(
586                 modifier = Modifier
587                     .matchParentSize()
588                     .then(
589                         if (enabled) Modifier else Modifier.alpha(0.38f)
590                     ),
591                 verticalAlignment = Alignment.CenterVertically,
592                 horizontalArrangement = Arrangement.SpaceBetween
593             ) {
594                 Icon(
595                     painter = leftIcon,
596                     contentDescription = leftIconDescription,
597                     modifier = Modifier.padding(iconPadding),
598                     tint = if (!enabled) {
599                         disableColor
600                     } else if (toggleState == ToggleState.Left) {
601                         iconSelectionColor
602                     } else {
603                         iconUnSelectionColor
604                     }
605                 )
606                 Icon(
607                     painter = rightIcon,
608                     contentDescription = rightIconDescription,
609                     modifier = Modifier.padding(iconPadding),
610                     tint = if (!enabled) {
611                         disableColor
612                     } else if (toggleState == ToggleState.Right) {
613                         iconSelectionColor
614                     } else {
615                         iconUnSelectionColor
616                     }
617                 )
618             }
619         }
620     }
621 }
622 
623 @Preview(name = "Light Mode")
624 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
625 @Composable
Preview_ToggleButton_Selecting_Leftnull626 private fun Preview_ToggleButton_Selecting_Left() {
627     val initialState = ToggleState.Left
628     var toggleState by remember {
629         mutableStateOf(initialState)
630     }
631     PreviewPreviewTheme(dynamicColor = false) {
632         ToggleButton(
633             leftIcon = if (toggleState == ToggleState.Left) {
634                 rememberVectorPainter(image = Icons.Filled.CameraAlt)
635             } else {
636                 rememberVectorPainter(image = Icons.Outlined.CameraAlt)
637             },
638             rightIcon = if (toggleState == ToggleState.Right) {
639                 rememberVectorPainter(image = Icons.Filled.Videocam)
640             } else {
641                 rememberVectorPainter(image = Icons.Outlined.Videocam)
642             },
643             initialState = ToggleState.Left,
644             onToggleStateChanged = {
645                 toggleState = it
646             }
647         )
648     }
649 }
650 
651 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
652 @Composable
Preview_ToggleButton_Selecting_Rightnull653 private fun Preview_ToggleButton_Selecting_Right() {
654     PreviewPreviewTheme(dynamicColor = false) {
655         ToggleButton(
656             leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
657             rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
658             initialState = ToggleState.Right
659         )
660     }
661 }
662 
663 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
664 @Composable
Preview_ToggleButton_Disablednull665 private fun Preview_ToggleButton_Disabled() {
666     PreviewPreviewTheme(dynamicColor = false) {
667         ToggleButton(
668             leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
669             rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
670             initialState = ToggleState.Right,
671             enabled = false
672         )
673     }
674 }
675