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.photogrid
18
19 import androidx.compose.animation.animateContentSize
20 import androidx.compose.animation.expandVertically
21 import androidx.compose.animation.shrinkVertically
22 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.foundation.layout.Column
25 import androidx.compose.foundation.layout.PaddingValues
26 import androidx.compose.foundation.layout.Row
27 import androidx.compose.foundation.layout.Spacer
28 import androidx.compose.foundation.layout.fillMaxSize
29 import androidx.compose.foundation.layout.fillMaxWidth
30 import androidx.compose.foundation.layout.padding
31 import androidx.compose.foundation.layout.size
32 import androidx.compose.foundation.layout.width
33 import androidx.compose.foundation.lazy.grid.GridCells
34 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
35 import androidx.compose.material.icons.Icons
36 import androidx.compose.material.icons.outlined.Image
37 import androidx.compose.material3.Icon
38 import androidx.compose.material3.Text
39 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
40 import androidx.compose.runtime.Composable
41 import androidx.compose.runtime.LaunchedEffect
42 import androidx.compose.runtime.getValue
43 import androidx.compose.runtime.remember
44 import androidx.compose.runtime.rememberCoroutineScope
45 import androidx.compose.ui.Alignment
46 import androidx.compose.ui.Modifier
47 import androidx.compose.ui.input.pointer.pointerInput
48 import androidx.compose.ui.platform.LocalConfiguration
49 import androidx.compose.ui.res.stringResource
50 import androidx.compose.ui.semantics.contentDescription
51 import androidx.compose.ui.text.style.TextOverflow
52 import androidx.compose.ui.unit.dp
53 import androidx.lifecycle.compose.collectAsStateWithLifecycle
54 import androidx.paging.LoadState
55 import androidx.paging.compose.collectAsLazyPagingItems
56 import com.android.modules.utils.build.SdkLevel
57 import com.android.photopicker.R
58 import com.android.photopicker.core.StateSelector
59 import com.android.photopicker.core.animations.standardDecelerate
60 import com.android.photopicker.core.banners.Banner
61 import com.android.photopicker.core.banners.BannerDefinitions
62 import com.android.photopicker.core.components.EmptyState
63 import com.android.photopicker.core.components.MediaGridItem
64 import com.android.photopicker.core.components.getCellsPerRow
65 import com.android.photopicker.core.components.mediaGrid
66 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
67 import com.android.photopicker.core.configuration.PhotopickerRuntimeEnv
68 import com.android.photopicker.core.embedded.LocalEmbeddedState
69 import com.android.photopicker.core.events.Event
70 import com.android.photopicker.core.events.LocalEvents
71 import com.android.photopicker.core.events.Telemetry
72 import com.android.photopicker.core.features.FeatureToken
73 import com.android.photopicker.core.features.LocalFeatureManager
74 import com.android.photopicker.core.features.Location
75 import com.android.photopicker.core.hideWhenState
76 import com.android.photopicker.core.navigation.LocalNavController
77 import com.android.photopicker.core.navigation.PhotopickerDestinations
78 import com.android.photopicker.core.navigation.PhotopickerDestinations.PHOTO_GRID
79 import com.android.photopicker.core.obtainViewModel
80 import com.android.photopicker.core.selection.LocalSelection
81 import com.android.photopicker.core.theme.LocalWindowSizeClass
82 import com.android.photopicker.extensions.navigateToAlbumGrid
83 import com.android.photopicker.extensions.navigateToCategoryGrid
84 import com.android.photopicker.extensions.navigateToPhotoGrid
85 import com.android.photopicker.extensions.navigateToPreviewMedia
86 import com.android.photopicker.features.albumgrid.AlbumGridFeature
87 import com.android.photopicker.features.categorygrid.CategoryGridFeature
88 import com.android.photopicker.features.navigationbar.NavigationBarButton
89 import com.android.photopicker.features.preview.PreviewFeature
90 import com.android.photopicker.features.search.SearchFeature
91 import com.android.photopicker.util.LocalLocalizationHelper
92 import kotlinx.coroutines.launch
93
94 private val MEASUREMENT_BANNER_PADDING =
95 PaddingValues(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 24.dp)
96
97 // This is the number of rows we should include in the recents section at the top of the Photo Grid.
98 // The recents section does not contain any separators.
99 private val RECENTS_ROW_COUNT = 3
100
101 /**
102 * Primary composable for drawing the main PhotoGrid on [PhotopickerDestinations.PHOTO_GRID]
103 *
104 * @param viewModel - A viewModel override for the composable. Normally, this is fetched via hilt
105 * from the backstack entry by using obtainViewModel()
106 */
107 @Composable
108 fun PhotoGrid(viewModel: PhotoGridViewModel = obtainViewModel()) {
109 val navController = LocalNavController.current
110 val featureManager = LocalFeatureManager.current
111 val isPreviewEnabled = remember { featureManager.isFeatureEnabled(PreviewFeature::class.java) }
112
113 val state = rememberLazyGridState()
114
115 val selection by LocalSelection.current.flow.collectAsStateWithLifecycle()
116
117 /* Use the expanded layout any time the Width is Medium or larger. */
118 val isExpandedScreen: Boolean =
119 when (LocalWindowSizeClass.current.widthSizeClass) {
120 WindowWidthSizeClass.Medium -> true
121 WindowWidthSizeClass.Expanded -> true
122 else -> false
123 }
124
125 val cellsPerRow = remember(isExpandedScreen) { getCellsPerRow(isExpandedScreen) }
126
127 val items =
128 viewModel
129 .getData(/* recentsCellCount */ (cellsPerRow * RECENTS_ROW_COUNT))
130 .collectAsLazyPagingItems()
131
132 val selectionLimit = LocalPhotopickerConfiguration.current.selectionLimit
133 val localizedSelectionLimit = LocalLocalizationHelper.current.getLocalizedCount(selectionLimit)
134
135 val selectionLimitExceededMessage =
136 stringResource(
137 R.string.photopicker_selection_limit_exceeded_snackbar,
138 localizedSelectionLimit,
139 )
140
141 val events = LocalEvents.current
142 val scope = rememberCoroutineScope()
143 val configuration = LocalPhotopickerConfiguration.current
144
145 // Modifier applied when photo grid to album grid navigation is disabled
146 val baseModifier = Modifier.fillMaxSize()
147 // Modifier applied when photo grid to album grid navigation is enabled
148 val modifierWithNavigation =
149 Modifier.fillMaxSize().pointerInput(Unit) {
150 detectHorizontalDragGestures(
151 onHorizontalDrag = { _, dragAmount ->
152 // This may need some additional fine tuning by looking at a certain
153 // distance in dragAmount, but initial testing suggested this worked
154 // pretty well as is.
155 if (dragAmount < 0) {
156 // Negative is a left swipe
157 if (featureManager.isFeatureEnabled(AlbumGridFeature::class.java)) {
158 // Dispatch UI event to indicate switching to albums tab
159 scope.launch {
160 events.dispatch(
161 Event.LogPhotopickerUIEvent(
162 FeatureToken.ALBUM_GRID.token,
163 configuration.sessionId,
164 configuration.callingPackageUid ?: -1,
165 Telemetry.UiEvent.SWITCH_PICKER_TAB,
166 )
167 )
168 }
169 navController.navigateToAlbumGrid()
170 } else if (
171 featureManager.isFeatureEnabled(CategoryGridFeature::class.java)
172 ) {
173 // Dispatch UI event to indicate switching to collections tab
174 scope.launch {
175 events.dispatch(
176 Event.LogPhotopickerUIEvent(
177 FeatureToken.CATEGORY_GRID.token,
178 configuration.sessionId,
179 configuration.callingPackageUid ?: -1,
180 Telemetry.UiEvent.SWITCH_PICKER_TAB,
181 )
182 )
183 }
184 navController.navigateToCategoryGrid()
185 }
186 }
187 }
188 )
189 }
190
191 val isEmbedded =
192 LocalPhotopickerConfiguration.current.runtimeEnv == PhotopickerRuntimeEnv.EMBEDDED
193 val isExpanded = LocalEmbeddedState.current?.isExpanded ?: false
194 val isEmbeddedAndCollapsed = isEmbedded && !isExpanded
195 val host = LocalEmbeddedState.current?.host
196
197 Column(
198 modifier =
199 when (isEmbeddedAndCollapsed) {
200 true -> baseModifier
201 false -> modifierWithNavigation
202 }
203 ) {
204 val isEmptyAndNoMorePages =
205 items.itemCount == 0 &&
206 items.loadState.source.append is LoadState.NotLoading &&
207 items.loadState.source.append.endOfPaginationReached
208
209 when {
210 isEmptyAndNoMorePages -> {
211 val localConfig = LocalConfiguration.current
212 val emptyStatePadding =
213 remember(localConfig) { (localConfig.screenHeightDp * .20).dp }
214 EmptyState(
215 modifier =
216 if (SdkLevel.isAtLeastU() && isEmbedded && host != null) {
217 // In embedded no need to give extra top padding to make empty
218 // state title and body clearly visible in collapse mode (small view)
219 Modifier.fillMaxWidth()
220 } else {
221 // Provide 20% of screen height as empty space above
222 Modifier.fillMaxWidth().padding(top = emptyStatePadding)
223 },
224 icon = Icons.Outlined.Image,
225 title = stringResource(R.string.photopicker_photos_empty_state_title),
226 body = stringResource(R.string.photopicker_photos_empty_state_body),
227 )
228 }
229 else -> {
230
231 // When the PhotoGrid is ready to show, also collect the latest banner
232 // data from [BannerManager] so it can be placed inside of the mediaGrid's
233 // scroll container.
234 val currentBanner by viewModel.banners.collectAsStateWithLifecycle()
235
236 mediaGrid(
237 items = items,
238 isExpandedScreen = isExpandedScreen,
239 selection = selection,
240 bannerContent = {
241 hideWhenState(
242 selector =
243 object : StateSelector.AnimatedVisibilityInEmbedded {
244 override val visible =
245 LocalEmbeddedState.current?.isExpanded ?: false
246 override val enter =
247 expandVertically(animationSpec = standardDecelerate(300))
248 override val exit =
249 shrinkVertically(animationSpec = standardDecelerate(150))
250 }
251 ) {
252 AnimatedBannerWrapper(currentBanner)
253 }
254 },
255 onItemClick = { item ->
256 if (item is MediaGridItem.MediaItem) {
257 viewModel.handleGridItemSelection(
258 item = item.media,
259 selectionLimitExceededMessage = selectionLimitExceededMessage,
260 )
261 // Log user's interaction with picker's main grid(photo grid)
262 scope.launch {
263 events.dispatch(
264 Event.LogPhotopickerUIEvent(
265 FeatureToken.PHOTO_GRID.token,
266 configuration.sessionId,
267 configuration.callingPackageUid ?: -1,
268 Telemetry.UiEvent.PICKER_MAIN_GRID_INTERACTION,
269 )
270 )
271 }
272 }
273 },
274 onItemLongPress = { item ->
275 // If the [PreviewFeature] is enabled, launch the preview route.
276 if (isPreviewEnabled) {
277 // Log long pressing a media item in the photo grid
278 scope.launch {
279 events.dispatch(
280 Event.LogPhotopickerUIEvent(
281 FeatureToken.PREVIEW.token,
282 configuration.sessionId,
283 configuration.callingPackageUid ?: -1,
284 Telemetry.UiEvent.PICKER_LONG_SELECT_MEDIA_ITEM,
285 )
286 )
287 }
288 if (item is MediaGridItem.MediaItem) {
289 // Log entry into the photopicker preview mode
290 scope.launch {
291 events.dispatch(
292 Event.LogPhotopickerUIEvent(
293 FeatureToken.PREVIEW.token,
294 configuration.sessionId,
295 configuration.callingPackageUid ?: -1,
296 Telemetry.UiEvent.ENTER_PICKER_PREVIEW_MODE,
297 )
298 )
299 }
300 navController.navigateToPreviewMedia(item.media)
301 }
302 }
303 },
304 columns = GridCells.Fixed(cellsPerRow),
305 state = state,
306 )
307 LaunchedEffect(Unit) {
308 // Log loading of photos in the photo grid
309 events.dispatch(
310 Event.LogPhotopickerUIEvent(
311 FeatureToken.PHOTO_GRID.token,
312 configuration.sessionId,
313 configuration.callingPackageUid ?: -1,
314 Telemetry.UiEvent.UI_LOADED_PHOTOS,
315 )
316 )
317 }
318 }
319 }
320 }
321 }
322
323 /**
324 * A container that animates its size to show the banner if one is defined. It also handles the
325 * banner's onDismiss action by sending the dismissal to the [PhotoGridViewModel].
326 *
327 * @param currentBanner The current banner that [BannerManager] is exposing.
328 */
329 @Composable
AnimatedBannerWrappernull330 private fun AnimatedBannerWrapper(
331 currentBanner: Banner?,
332 viewModel: PhotoGridViewModel = obtainViewModel(),
333 ) {
334 Box(modifier = Modifier.animateContentSize()) {
335 currentBanner?.let {
336 Banner(
337 it,
338 modifier = Modifier.padding(MEASUREMENT_BANNER_PADDING),
339 onDismiss = {
340 val declaration = it.declaration
341
342 // Coerce the type back to [BannerDefinitions]
343 // so that it can be dismissed.
344 if (declaration is BannerDefinitions) {
345 viewModel.markBannerAsDismissed(declaration)
346 }
347 },
348 )
349 }
350 }
351 }
352
353 /**
354 * The navigation button for the main photo grid. Composable for
355 * [Location.NAVIGATION_BAR_NAV_BUTTON]
356 */
357 @Composable
PhotoGridNavButtonnull358 fun PhotoGridNavButton(modifier: Modifier) {
359 val navController = LocalNavController.current
360 val scope = rememberCoroutineScope()
361 val events = LocalEvents.current
362 val configuration = LocalPhotopickerConfiguration.current
363 val featureManager = LocalFeatureManager.current
364 val categoryFeatureEnabled = featureManager.isFeatureEnabled(CategoryGridFeature::class.java)
365 val searchFeatureEnabled = featureManager.isFeatureEnabled(SearchFeature::class.java)
366
367 NavigationBarButton(
368 onClick = {
369 // Log switching tab to the photos tab
370 scope.launch {
371 events.dispatch(
372 Event.LogPhotopickerUIEvent(
373 FeatureToken.PHOTO_GRID.token,
374 configuration.sessionId,
375 configuration.callingPackageUid ?: -1,
376 Telemetry.UiEvent.SWITCH_PICKER_TAB,
377 )
378 )
379 }
380 navController.navigateToPhotoGrid()
381 },
382 modifier = modifier,
383 isCurrentRoute = { route -> route == PHOTO_GRID.route },
384 ) {
385 when {
386 categoryFeatureEnabled && searchFeatureEnabled -> {
387 Row(verticalAlignment = Alignment.CenterVertically) {
388 Icon(
389 imageVector = Icons.Outlined.Image,
390 contentDescription = null,
391 modifier = Modifier.size(18.dp),
392 )
393 Spacer(Modifier.width(8.dp))
394 Text(
395 stringResource(R.string.photopicker_photos_nav_button_label),
396 maxLines = 1, // Limit the text to a single line
397 overflow = TextOverflow.Ellipsis,
398 )
399 }
400 }
401 else -> Text(stringResource(R.string.photopicker_photos_nav_button_label))
402 }
403 }
404 }
405