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