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