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.layout.Arrangement
20 import androidx.compose.foundation.layout.Box
21 import androidx.compose.foundation.layout.Column
22 import androidx.compose.foundation.layout.Row
23 import androidx.compose.foundation.layout.WindowInsets
24 import androidx.compose.foundation.layout.WindowInsetsSides
25 import androidx.compose.foundation.layout.fillMaxSize
26 import androidx.compose.foundation.layout.fillMaxWidth
27 import androidx.compose.foundation.layout.only
28 import androidx.compose.foundation.layout.padding
29 import androidx.compose.foundation.layout.systemBars
30 import androidx.compose.foundation.layout.widthIn
31 import androidx.compose.foundation.layout.windowInsetsPadding
32 import androidx.compose.foundation.pager.HorizontalPager
33 import androidx.compose.foundation.pager.rememberPagerState
34 import androidx.compose.material3.ButtonDefaults
35 import androidx.compose.material3.FilledTonalButton
36 import androidx.compose.material3.MaterialTheme
37 import androidx.compose.material3.SnackbarHost
38 import androidx.compose.material3.SnackbarHostState
39 import androidx.compose.material3.Surface
40 import androidx.compose.material3.Text
41 import androidx.compose.material3.TextButton
42 import androidx.compose.runtime.Composable
43 import androidx.compose.runtime.LaunchedEffect
44 import androidx.compose.runtime.getValue
45 import androidx.compose.runtime.mutableStateOf
46 import androidx.compose.runtime.remember
47 import androidx.compose.runtime.rememberCoroutineScope
48 import androidx.compose.runtime.setValue
49 import androidx.compose.ui.Alignment
50 import androidx.compose.ui.Modifier
51 import androidx.compose.ui.graphics.Color
52 import androidx.compose.ui.res.stringResource
53 import androidx.compose.ui.unit.dp
54 import androidx.lifecycle.compose.collectAsStateWithLifecycle
55 import com.android.photopicker.R
56 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
57 import com.android.photopicker.core.events.Event
58 import com.android.photopicker.core.events.LocalEvents
59 import com.android.photopicker.core.features.FeatureToken.PREVIEW
60 import com.android.photopicker.core.glide.RESOLUTION_REQUESTED
61 import com.android.photopicker.core.glide.Resolution
62 import com.android.photopicker.core.glide.loadMedia
63 import com.android.photopicker.core.navigation.LocalNavController
64 import com.android.photopicker.core.obtainViewModel
65 import com.android.photopicker.core.selection.LocalSelection
66 import com.android.photopicker.core.theme.CustomAccentColorScheme
67 import com.android.photopicker.data.model.Media
68 import com.android.photopicker.extensions.navigateToPreviewSelection
69 import kotlinx.coroutines.flow.StateFlow
70 import kotlinx.coroutines.launch
71 
72 /* The minimum width for the selection toggle button */
73 private val MEASUREMENT_SELECTION_BUTTON_MIN_WIDTH = 150.dp
74 
75 /* The amount of padding around the selection bar at the bottom of the layout. */
76 private val MEASUREMENT_SELECTION_BAR_PADDING = 12.dp
77 
78 /** Padding between the bottom edge of the screen and the snackbars */
79 private val MEASUREMENT_SNACKBAR_BOTTOM_PADDING = 48.dp
80 
81 /**
82  * Entry point for the [PhotopickerDestinations.PREVIEW_SELECTION] route.
83  *
84  * This composable will snapshot the current selection when created so that photos are not removed
85  * from the list of preview-able photos.
86  */
87 @Composable
88 fun PreviewSelection(viewModel: PreviewViewModel = obtainViewModel()) {
89     val selection by viewModel.selectionSnapshot.collectAsStateWithLifecycle()
90 
91     // Only snapshot the selection once when the composable is created.
92     LaunchedEffect(Unit) { viewModel.takeNewSelectionSnapshot() }
93 
94     Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
95         Column(
96             modifier =
97                 // This is inside an edge-to-edge dialog, so apply padding to ensure the
98                 // UI buttons stay above the navigation bar.
99                 Modifier.windowInsetsPadding(
100                     WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
101                 )
102         ) {
103             when {
104                 selection.isEmpty() -> {}
105                 else -> Preview(selection)
106             }
107         }
108     }
109 }
110 
111 /**
112  * Entry point for the [PhotopickerDestinations.PREVIEW_MEDIA] route.
113  *
114  * @param previewItemFlow - A [StateFlow] from the navBackStackEntry savedStateHandler which uses
115  *   the [PreviewFeature.PREVIEW_MEDIA_KEY] to retrieve the passed [Media] item to preview.
116  */
117 @Composable
PreviewMedianull118 fun PreviewMedia(
119     previewItemFlow: StateFlow<Media?>,
120 ) {
121     val media by previewItemFlow.collectAsStateWithLifecycle()
122     val selection by LocalSelection.current.flow.collectAsStateWithLifecycle()
123     // create a local variable for the when block so the compiler doesn't complain about the
124     // delegate.
125     val localMedia = media
126 
127     /** SnackbarHost api for launching Snackbars */
128     val snackbarHostState = remember { SnackbarHostState() }
129     val scope = rememberCoroutineScope()
130 
131     Box {
132         Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
133             Box(
134                 modifier = Modifier.padding(vertical = 50.dp),
135                 contentAlignment = Alignment.Center
136             ) {
137                 // Preview session state to keep track if the video player's audio is muted.
138                 var audioIsMuted by remember { mutableStateOf(true) }
139                 when (localMedia) {
140                     is Media.Image -> ImageUi(localMedia)
141                     is Media.Video ->
142                         VideoUi(localMedia, audioIsMuted, { audioIsMuted = it }, snackbarHostState)
143                     null -> {}
144                 }
145             }
146         }
147 
148         Column(
149             modifier =
150                 Modifier.fillMaxWidth()
151                     .align(Alignment.BottomCenter)
152                     // This is inside an edge-to-edge dialog, so apply padding to ensure the
153                     // selection button stays above the navigation bar.
154                     .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Vertical)),
155             horizontalAlignment = Alignment.CenterHorizontally,
156         ) {
157 
158             // Photopicker is (generally) inside of a BottomSheet, and the preview route is inside a
159             // dialog, so this requires a custom [SnackbarHost] to draw on top of those elements
160             // that do not play nicely with snackbars. Peace was never an option.
161             SnackbarHost(snackbarHostState)
162 
163             // Once a media item is loaded, display the selection toggles at the bottom.
164             if (localMedia != null) {
165                 val viewModel: PreviewViewModel = obtainViewModel()
166                 Row {
167                     val selectionLimit = LocalPhotopickerConfiguration.current.selectionLimit
168                     val selectionLimitExceededMessage =
169                         stringResource(
170                             R.string.photopicker_selection_limit_exceeded_snackbar,
171                             selectionLimit
172                         )
173 
174                     FilledTonalButton(
175                         modifier = Modifier.widthIn(min = MEASUREMENT_SELECTION_BUTTON_MIN_WIDTH),
176                         onClick = {
177                             viewModel.toggleInSelection(
178                                 media = localMedia,
179                                 onSelectionLimitExceeded = {
180                                     scope.launch {
181                                         snackbarHostState.showSnackbar(
182                                             selectionLimitExceededMessage
183                                         )
184                                     }
185                                 }
186                             )
187                         },
188                     ) {
189                         Text(
190                             if (selection.contains(localMedia))
191                                 stringResource(R.string.photopicker_deselect_button_label)
192                             else stringResource(R.string.photopicker_select_button_label)
193                         )
194                     }
195                 }
196             }
197         }
198     }
199 }
200 
201 /**
202  * Composable that creates a [HorizontalPager] and shows items in the provided selection set.
203  *
204  * @param selection selected items that should be included in the pager.
205  */
206 @Composable
Previewnull207 private fun Preview(selection: Set<Media>) {
208     val viewModel: PreviewViewModel = obtainViewModel()
209     val currentSelection by LocalSelection.current.flow.collectAsStateWithLifecycle()
210     val events = LocalEvents.current
211     val scope = rememberCoroutineScope()
212 
213     // Preview session state to keep track if the video player's audio is muted.
214     var audioIsMuted by remember { mutableStateOf(true) }
215 
216     /** SnackbarHost api for launching Snackbars */
217     val snackbarHostState = remember { SnackbarHostState() }
218 
219     // Page count equal to size of selection
220     val state = rememberPagerState { selection.size }
221     Box(modifier = Modifier.fillMaxSize()) {
222         HorizontalPager(
223             state = state,
224             modifier = Modifier.fillMaxSize(),
225         ) { page ->
226             val media = selection.elementAt(page)
227 
228             when (media) {
229                 is Media.Image -> ImageUi(media)
230                 is Media.Video ->
231                     VideoUi(media, audioIsMuted, { audioIsMuted = it }, snackbarHostState)
232             }
233         }
234 
235         // Photopicker is (generally) inside of a BottomSheet, and the preview route is inside a
236         // dialog, so this requires a custom [SnackbarHost] to draw on top of those elements that do
237         // not play nicely with snackbars. Peace was never an option.
238         SnackbarHost(
239             snackbarHostState,
240             modifier =
241                 Modifier.align(Alignment.BottomCenter)
242                     .padding(bottom = MEASUREMENT_SNACKBAR_BOTTOM_PADDING)
243         )
244 
245         // Bottom row of action buttons
246         Row(
247             modifier =
248                 Modifier.align(Alignment.BottomCenter)
249                     .fillMaxWidth()
250                     .padding(MEASUREMENT_SELECTION_BAR_PADDING),
251             horizontalArrangement = Arrangement.SpaceBetween,
252         ) {
253             val selectionLimit = LocalPhotopickerConfiguration.current.selectionLimit
254             val selectionLimitExceededMessage =
255                 stringResource(
256                     R.string.photopicker_selection_limit_exceeded_snackbar,
257                     selectionLimit
258                 )
259             FilledTonalButton(
260                 modifier =
261                     Modifier.widthIn(
262                         // Apply a min width to prevent the button re-sizing when the label changes.
263                         min = MEASUREMENT_SELECTION_BUTTON_MIN_WIDTH,
264                     ),
265                 onClick = {
266                     viewModel.toggleInSelection(
267                         media = selection.elementAt(state.currentPage),
268                         onSelectionLimitExceeded = {
269                             scope.launch {
270                                 snackbarHostState.showSnackbar(selectionLimitExceededMessage)
271                             }
272                         }
273                     )
274                 },
275             ) {
276                 Text(
277                     if (currentSelection.contains(selection.elementAt(state.currentPage)))
278                     // Label: Deselect
279                     stringResource(R.string.photopicker_deselect_button_label)
280                     // Label: Select
281                     else stringResource(R.string.photopicker_select_button_label),
282                     color =
283                         CustomAccentColorScheme.current.getAccentColorIfDefinedOrElse(
284                             MaterialTheme.colorScheme.primary
285                         ),
286                 )
287             }
288 
289             // Similar button to the Add button on the Selection bar. Clicking this will confirm
290             // the current selection and end the session.
291             FilledTonalButton(
292                 onClick = {
293                     scope.launch { events.dispatch(Event.MediaSelectionConfirmed(PREVIEW.token)) }
294                 },
295                 colors =
296                     ButtonDefaults.filledTonalButtonColors(
297                         containerColor =
298                             CustomAccentColorScheme.current.getAccentColorIfDefinedOrElse(
299                                 /* fallback */ MaterialTheme.colorScheme.primary
300                             ),
301                         contentColor =
302                             CustomAccentColorScheme.current
303                                 .getTextColorForAccentComponentsIfDefinedOrElse(
304                                     /* fallback */ MaterialTheme.colorScheme.onPrimary
305                                 ),
306                     )
307             ) {
308                 Text(
309                     stringResource(
310                         // Label: Add (N)
311                         R.string.photopicker_add_button_label,
312                         currentSelection.size,
313                     )
314                 )
315             }
316         }
317     }
318 }
319 
320 /**
321  * Composable that loads a [Media.Image] in [Resolution.FULL] for the user to preview.
322  *
323  * @param image
324  */
325 @Composable
ImageUinull326 private fun ImageUi(image: Media.Image) {
327     loadMedia(
328         media = image,
329         resolution = Resolution.FULL,
330         modifier = Modifier.fillMaxWidth(),
331         // by default loadMedia center crops, so use a custom request builder
332         requestBuilderTransformation = { media, resolution, builder ->
333             builder.set(RESOLUTION_REQUESTED, resolution).signature(media.getSignature(resolution))
334         }
335     )
336 }
337 
338 /**
339  * Composable for [Location.SELECTION_BAR_SECONDARY_ACTION] Creates a button that launches the
340  * [PhotopickerDestinations.PREVIEW_SELECTION] route.
341  */
342 @Composable
PreviewSelectionButtonnull343 fun PreviewSelectionButton(modifier: Modifier) {
344     val navController = LocalNavController.current
345 
346     TextButton(
347         onClick = navController::navigateToPreviewSelection,
348         modifier = modifier,
349     ) {
350         Text(
351             stringResource(R.string.photopicker_preview_button_label),
352             color =
353                 CustomAccentColorScheme.current.getAccentColorIfDefinedOrElse(
354                     /* fallback */ MaterialTheme.colorScheme.primary
355                 )
356         )
357     }
358 }
359