• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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