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