1 /*
<lambda>null2 * Copyright 2024 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
17 package com.android.photopicker.features.preview
18
19 import androidx.compose.foundation.background
20 import androidx.compose.foundation.focusable
21 import androidx.compose.foundation.layout.Arrangement
22 import androidx.compose.foundation.layout.Box
23 import androidx.compose.foundation.layout.Column
24 import androidx.compose.foundation.layout.Row
25 import androidx.compose.foundation.layout.Spacer
26 import androidx.compose.foundation.layout.WindowInsets
27 import androidx.compose.foundation.layout.WindowInsetsSides
28 import androidx.compose.foundation.layout.fillMaxSize
29 import androidx.compose.foundation.layout.fillMaxWidth
30 import androidx.compose.foundation.layout.only
31 import androidx.compose.foundation.layout.padding
32 import androidx.compose.foundation.layout.size
33 import androidx.compose.foundation.layout.systemBars
34 import androidx.compose.foundation.layout.windowInsetsPadding
35 import androidx.compose.foundation.pager.HorizontalPager
36 import androidx.compose.foundation.pager.PagerState
37 import androidx.compose.foundation.pager.rememberPagerState
38 import androidx.compose.foundation.shape.CircleShape
39 import androidx.compose.material.icons.Icons
40 import androidx.compose.material.icons.automirrored.filled.ArrowBack
41 import androidx.compose.material.icons.filled.PhotoLibrary
42 import androidx.compose.material.icons.outlined.Circle
43 import androidx.compose.material3.ButtonDefaults
44 import androidx.compose.material3.FilledTonalButton
45 import androidx.compose.material3.Icon
46 import androidx.compose.material3.IconButton
47 import androidx.compose.material3.MaterialTheme
48 import androidx.compose.material3.SnackbarHost
49 import androidx.compose.material3.SnackbarHostState
50 import androidx.compose.material3.Surface
51 import androidx.compose.material3.Text
52 import androidx.compose.material3.TextButton
53 import androidx.compose.runtime.Composable
54 import androidx.compose.runtime.LaunchedEffect
55 import androidx.compose.runtime.getValue
56 import androidx.compose.runtime.mutableStateOf
57 import androidx.compose.runtime.remember
58 import androidx.compose.runtime.rememberCoroutineScope
59 import androidx.compose.runtime.setValue
60 import androidx.compose.ui.Alignment
61 import androidx.compose.ui.Modifier
62 import androidx.compose.ui.focus.focusRequester
63 import androidx.compose.ui.graphics.Color
64 import androidx.compose.ui.graphics.vector.ImageVector
65 import androidx.compose.ui.res.stringResource
66 import androidx.compose.ui.res.vectorResource
67 import androidx.compose.ui.semantics.onClick
68 import androidx.compose.ui.semantics.semantics
69 import androidx.compose.ui.semantics.traversalIndex
70 import androidx.compose.ui.unit.dp
71 import androidx.lifecycle.compose.collectAsStateWithLifecycle
72 import androidx.paging.compose.LazyPagingItems
73 import androidx.paging.compose.collectAsLazyPagingItems
74 import com.android.photopicker.R
75 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
76 import com.android.photopicker.core.configuration.PhotopickerConfiguration
77 import com.android.photopicker.core.events.Event
78 import com.android.photopicker.core.events.Events
79 import com.android.photopicker.core.events.LocalEvents
80 import com.android.photopicker.core.events.Telemetry
81 import com.android.photopicker.core.features.FeatureToken
82 import com.android.photopicker.core.features.Location
83 import com.android.photopicker.core.glide.RESOLUTION_REQUESTED
84 import com.android.photopicker.core.glide.Resolution
85 import com.android.photopicker.core.glide.loadMedia
86 import com.android.photopicker.core.navigation.LocalNavController
87 import com.android.photopicker.core.navigation.PhotopickerDestinations
88 import com.android.photopicker.core.obtainViewModel
89 import com.android.photopicker.core.selection.LocalSelection
90 import com.android.photopicker.core.selection.SelectionStrategy
91 import com.android.photopicker.core.selection.SelectionStrategy.Companion.determineSelectionStrategy
92 import com.android.photopicker.core.theme.CustomAccentColorScheme
93 import com.android.photopicker.core.theme.LocalFixedAccentColors
94 import com.android.photopicker.data.model.Media
95 import com.android.photopicker.extensions.navigateToPreviewSelection
96 import com.android.photopicker.util.HierarchicalFocusCoordinator
97 import com.android.photopicker.util.LocalLocalizationHelper
98 import com.android.photopicker.util.getMediaContentDescription
99 import com.android.photopicker.util.rememberActiveFocusRequester
100 import java.text.DateFormat
101 import kotlinx.coroutines.flow.StateFlow
102 import kotlinx.coroutines.launch
103
104 /**
105 * Entry point for the [PhotopickerDestinations.PREVIEW_SELECTION] and
106 * [PhotopickerDestinations.PREVIEW_MEDIA]route.
107 *
108 * This composable will snapshot the current selection when created so that photos are not removed
109 * from the list of preview-able photos.
110 */
111 @Composable
112 fun PreviewSelection(
113 viewModel: PreviewViewModel = obtainViewModel(),
114 previewItemFlow: StateFlow<Media?>? = null,
115 ) {
116 val currentSelection by LocalSelection.current.flow.collectAsStateWithLifecycle()
117
118 val previewSingleItem =
119 when (previewItemFlow) {
120 null -> false
121 else -> true
122 }
123
124 val selection =
125 when (previewSingleItem) {
126 true -> {
127 checkNotNull(previewItemFlow) { "Flow cannot be null for previewSingleItem" }
128 val media by previewItemFlow.collectAsStateWithLifecycle()
129 val localMedia = media
130 if (localMedia != null) {
131 viewModel
132 .getPreviewMediaIncludingPreGrantedItems(
133 setOf(localMedia),
134 LocalPhotopickerConfiguration.current,
135 /* isSingleItemPreview */ true,
136 )
137 .collectAsLazyPagingItems()
138 } else {
139 null
140 }
141 }
142 false -> {
143 val selectionSnapshot by viewModel.selectionSnapshot.collectAsStateWithLifecycle()
144 viewModel
145 .getPreviewMediaIncludingPreGrantedItems(
146 selectionSnapshot,
147 LocalPhotopickerConfiguration.current,
148 /* isSingleItemPreview */ false,
149 )
150 .collectAsLazyPagingItems()
151 }
152 }
153
154 if (selection != null) {
155 val dateFormat =
156 LocalLocalizationHelper.current.getLocalizedDateTimeFormatter(
157 DateFormat.MEDIUM,
158 DateFormat.SHORT,
159 )
160 // Only snapshot the selection once when the composable is created.
161 LaunchedEffect(Unit) { viewModel.takeNewSelectionSnapshot() }
162 val navController = LocalNavController.current
163
164 Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
165 Column(
166 modifier =
167 // This is inside an edge-to-edge dialog, so apply padding to ensure the
168 // UI buttons stay above the navigation bar.
169 Modifier.windowInsetsPadding(
170 WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
171 )
172 ) {
173 Row(
174 modifier =
175 Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 4.dp, start = 8.dp)
176 ) {
177 // back button
178 IconButton(onClick = { navController.popBackStack() }) {
179 Icon(
180 imageVector = Icons.AutoMirrored.Filled.ArrowBack,
181 // For accessibility
182 contentDescription = stringResource(R.string.photopicker_back_option),
183 tint = Color.White,
184 )
185 }
186 }
187
188 /** SnackbarHost api for launching Snackbars */
189 val snackbarHostState = remember { SnackbarHostState() }
190
191 // Page count equal to size of selection
192 val state = rememberPagerState { selection.itemCount }
193
194 Box(modifier = Modifier.weight(1f)) {
195 if (selection.itemCount > 0) {
196 // Add the pager to show the media.
197 PreviewPager(
198 Modifier.align(Alignment.Center),
199 selection,
200 state,
201 snackbarHostState,
202 /* singleItemPreview */ previewSingleItem,
203 dateFormat,
204 )
205
206 // Only show the selection button if not in single select.
207 if (LocalPhotopickerConfiguration.current.selectionLimit > 1) {
208 IconButton(
209 modifier = Modifier.align(Alignment.TopStart).padding(start = 8.dp),
210 onClick = {
211 val media = selection.get(state.currentPage)
212 media?.let { viewModel.toggleInSelection(it, {}) }
213 },
214 ) {
215 if (currentSelection.contains(selection.get(state.currentPage))) {
216 val deselectActionLabel =
217 stringResource(
218 R.string.photopicker_deselect_action_description
219 )
220 Icon(
221 ImageVector.vectorResource(
222 R.drawable.photopicker_selected_media
223 ),
224 modifier =
225 Modifier
226 // Background is necessary because the icon has
227 // negative
228 // space.
229 .background(
230 MaterialTheme.colorScheme.onPrimary,
231 CircleShape,
232 )
233 .semantics {
234 onClick(
235 label = deselectActionLabel,
236 action = null,
237 )
238 },
239 contentDescription =
240 stringResource(R.string.photopicker_item_selected),
241 tint =
242 CustomAccentColorScheme.current
243 .getAccentColorIfDefinedOrElse(
244 /* fallback */ MaterialTheme.colorScheme.primary
245 ),
246 )
247 } else {
248 val selectActionLabel =
249 stringResource(
250 R.string.photopicker_select_action_description
251 )
252 Icon(
253 Icons.Outlined.Circle,
254 contentDescription =
255 stringResource(R.string.photopicker_item_not_selected),
256 tint = Color.White,
257 modifier =
258 Modifier.semantics {
259 onClick(label = selectActionLabel, action = null)
260 },
261 )
262 }
263 }
264 }
265 }
266 // Photopicker is (generally) inside of a BottomSheet, and the preview route
267 // is inside a dialog, so this requires a custom [SnackbarHost] to draw on
268 // top of those elements that do not play nicely with snackbars. Peace was
269 // never an option.
270 SnackbarHost(
271 snackbarHostState,
272 modifier = Modifier.align(Alignment.BottomCenter),
273 )
274 }
275
276 // Bottom row of action buttons
277 Row(
278 modifier =
279 Modifier.fillMaxWidth()
280 .padding(bottom = 48.dp, start = 4.dp, end = 16.dp, top = 12.dp),
281 horizontalArrangement = Arrangement.SpaceBetween,
282 ) {
283 val config = LocalPhotopickerConfiguration.current
284 val strategy = remember(config) { determineSelectionStrategy(config) }
285 if (previewSingleItem || strategy == SelectionStrategy.GRANTS_AWARE_SELECTION) {
286 Spacer(Modifier.size(8.dp))
287 } else {
288 SelectionButton(currentSelection = currentSelection)
289 }
290
291 FilledTonalButton(
292 onClick = {
293 if (config.selectionLimit == 1) {
294 val media = selection.get(state.currentPage)
295 media?.let { viewModel.toggleInSelection(it, {}) }
296 } else {
297 navController.popBackStack()
298 }
299 },
300 colors =
301 ButtonDefaults.filledTonalButtonColors(
302 containerColor =
303 CustomAccentColorScheme.current.getAccentColorIfDefinedOrElse(
304 /* fallback */ MaterialTheme.colorScheme.primary
305 ),
306 contentColor =
307 CustomAccentColorScheme.current
308 .getTextColorForAccentComponentsIfDefinedOrElse(
309 /* fallback */ MaterialTheme.colorScheme.onPrimary
310 ),
311 ),
312 ) {
313 Text(
314 text =
315 when (config.selectionLimit) {
316 1 ->
317 stringResource(
318 R.string.photopicker_select_current_button_label
319 )
320 else -> stringResource(R.string.photopicker_done_button_label)
321 }
322 )
323 }
324 }
325 }
326 }
327 }
328 }
329
330 @Composable
SelectionButtonnull331 private fun SelectionButton(
332 currentSelection: Set<Media>,
333 viewModel: PreviewViewModel = obtainViewModel(),
334 ) {
335 TextButton(
336 onClick = {
337 if (currentSelection.size > 0 && viewModel.selectionSnapshot.value.size > 0) {
338 // Deselect All in current selection
339 viewModel.toggleInSelection(currentSelection, {})
340 } else {
341 // Select All in snapshot
342 viewModel.toggleInSelection(viewModel.selectionSnapshot.value, {})
343 }
344 },
345 colors =
346 ButtonDefaults.textButtonColors(
347 contentColor =
348 // The background color for Preview is always fixed to Black, so when the
349 // custom accent color is defined, switch to a White color for this button
350 // so it doesn't clash with the custom color.
351 if (CustomAccentColorScheme.current.isAccentColorDefined()) Color.White
352 else LocalFixedAccentColors.current.primaryFixedDim
353 ),
354 ) {
355 val localizationHelper = LocalLocalizationHelper.current
356 if (currentSelection.size > 0) {
357 Icon(ImageVector.vectorResource(R.drawable.tab_close), contentDescription = null)
358 Spacer(Modifier.size(8.dp))
359 Text(
360 stringResource(
361 R.string.photopicker_deselect_button_label,
362 localizationHelper.getLocalizedCount(currentSelection.size),
363 )
364 )
365 } else {
366 Icon(Icons.Filled.PhotoLibrary, contentDescription = null)
367 Spacer(Modifier.size(8.dp))
368 Text(
369 stringResource(
370 R.string.photopicker_select_button_label,
371 localizationHelper.getLocalizedCount(viewModel.selectionSnapshot.value.size),
372 )
373 )
374 }
375 }
376 }
377
378 /**
379 * Composable that creates a [HorizontalPager] and shows items in the provided selection set.
380 *
381 * @param modifier
382 * @param selection selected items that should be included in the pager.
383 * @param state
384 * @param snackbarHostState
385 */
386 @Composable
PreviewPagernull387 private fun PreviewPager(
388 modifier: Modifier,
389 selection: LazyPagingItems<Media>,
390 state: PagerState,
391 snackbarHostState: SnackbarHostState,
392 singleItemPreview: Boolean,
393 dateFormat: DateFormat,
394 ) {
395 // Preview session state to keep track if the video player's audio is muted.
396 var audioIsMuted by remember { mutableStateOf(true) }
397
398 HorizontalPager(
399 state = state,
400 modifier = modifier.semantics(mergeDescendants = true) { traversalIndex = -1f },
401 ) { page ->
402 HierarchicalFocusCoordinator(requiresFocus = { state.currentPage == page }) {
403 val focusRequester = rememberActiveFocusRequester()
404 val media = selection.get(page)
405 if (media != null) {
406 Box(modifier = Modifier.focusRequester(focusRequester).focusable(true)) {
407 val pageDescription =
408 stringResource(
409 R.string.pohtopicker_horizontal_pager_description,
410 state.currentPage + 1,
411 state.pageCount,
412 )
413 val mediaDescription = getMediaContentDescription(media, dateFormat)
414 val contentDescription = mediaDescription + pageDescription
415 when (media) {
416 is Media.Image -> ImageUi(media, singleItemPreview, contentDescription)
417 is Media.Video ->
418 VideoUi(
419 media,
420 audioIsMuted,
421 { audioIsMuted = it },
422 snackbarHostState,
423 singleItemPreview,
424 contentDescription,
425 )
426 }
427 }
428 }
429 }
430 }
431 }
432
433 /**
434 * Composable that loads a [Media.Image] in [Resolution.FULL] for the user to preview.
435 *
436 * @param image
437 */
438 @Composable
ImageUinull439 private fun ImageUi(image: Media.Image, singleItemPreview: Boolean, contentDescription: String) {
440 if (singleItemPreview) {
441 val events = LocalEvents.current
442 val scope = rememberCoroutineScope()
443 val configuration = LocalPhotopickerConfiguration.current
444
445 scope.launch {
446 val mediaType =
447 if (image.mimeType.contains("gif")) {
448 Telemetry.MediaType.GIF
449 } else {
450 Telemetry.MediaType.PHOTO
451 }
452 // Mark entry into preview mode by long pressing on the media item
453 events.dispatch(
454 Event.LogPhotopickerPreviewInfo(
455 FeatureToken.PREVIEW.token,
456 configuration.sessionId,
457 Telemetry.PreviewModeEntry.LONG_PRESS,
458 previewItemCount = 1,
459 mediaType,
460 Telemetry.VideoPlayBackInteractions.UNSET_VIDEO_PLAYBACK_INTERACTION,
461 )
462 )
463 }
464 }
465 loadMedia(
466 media = image,
467 resolution = Resolution.FULL,
468 modifier = Modifier.fillMaxSize(),
469 contentDescription = contentDescription,
470 // by default loadMedia center crops, so use a custom request builder
471 requestBuilderTransformation = { media, resolution, builder ->
472 builder.set(RESOLUTION_REQUESTED, resolution).signature(media.getSignature(resolution))
473 },
474 )
475 }
476
477 /**
478 * Composable for [Location.SELECTION_BAR_SECONDARY_ACTION] Creates a button that launches the
479 * [PhotopickerDestinations.PREVIEW_SELECTION] route.
480 */
481 @Composable
PreviewSelectionButtonnull482 fun PreviewSelectionButton(modifier: Modifier) {
483 val navController = LocalNavController.current
484 val events = LocalEvents.current
485 val scope = rememberCoroutineScope()
486 // TODO(b/353659535): Use Selection.size api when available
487 val currentSelection by LocalSelection.current.flow.collectAsStateWithLifecycle()
488 val previewItemCount = currentSelection.size
489 val configuration = LocalPhotopickerConfiguration.current
490 if (currentSelection.isNotEmpty()) {
491 TextButton(
492 onClick = {
493 scope.launch {
494 logPreviewSelectionButtonClicked(configuration, previewItemCount, events)
495 }
496 navController.navigateToPreviewSelection()
497 },
498 modifier = modifier,
499 ) {
500 Text(
501 stringResource(R.string.photopicker_preview_button_label),
502 color =
503 CustomAccentColorScheme.current.getAccentColorIfDefinedOrElse(
504 /* fallback */ MaterialTheme.colorScheme.primary
505 ),
506 )
507 }
508 }
509 }
510
511 /**
512 * Dispatches all the relevant logging events for the picker's preview mode when the Preview button
513 * is clicked
514 */
logPreviewSelectionButtonClickednull515 private suspend fun logPreviewSelectionButtonClicked(
516 configuration: PhotopickerConfiguration,
517 previewItemCount: Int,
518 events: Events,
519 ) {
520 // Log preview item details
521 events.dispatch(
522 Event.LogPhotopickerPreviewInfo(
523 FeatureToken.PREVIEW.token,
524 configuration.sessionId,
525 Telemetry.PreviewModeEntry.VIEW_SELECTED,
526 previewItemCount,
527 Telemetry.MediaType.UNSET_MEDIA_TYPE,
528 Telemetry.VideoPlayBackInteractions.UNSET_VIDEO_PLAYBACK_INTERACTION,
529 )
530 )
531
532 // Log preview related UI events including clicking the 'preview' button
533 events.dispatch(
534 Event.LogPhotopickerUIEvent(
535 FeatureToken.PREVIEW.token,
536 configuration.sessionId,
537 configuration.callingPackageUid ?: -1,
538 Telemetry.UiEvent.ENTER_PICKER_PREVIEW_MODE,
539 )
540 )
541
542 events.dispatch(
543 Event.LogPhotopickerUIEvent(
544 FeatureToken.PREVIEW.token,
545 configuration.sessionId,
546 configuration.callingPackageUid ?: -1,
547 Telemetry.UiEvent.PICKER_CLICK_VIEW_SELECTED,
548 )
549 )
550 }
551