1 /*
<lambda>null2 * Copyright (C) 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.categorygrid
18
19 import androidx.compose.foundation.layout.Column
20 import androidx.compose.foundation.layout.fillMaxSize
21 import androidx.compose.foundation.layout.fillMaxWidth
22 import androidx.compose.foundation.layout.padding
23 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
24 import androidx.compose.material.icons.Icons
25 import androidx.compose.material.icons.outlined.Image
26 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
27 import androidx.compose.runtime.Composable
28 import androidx.compose.runtime.LaunchedEffect
29 import androidx.compose.runtime.getValue
30 import androidx.compose.runtime.remember
31 import androidx.compose.runtime.rememberCoroutineScope
32 import androidx.compose.ui.Modifier
33 import androidx.compose.ui.graphics.vector.ImageVector
34 import androidx.compose.ui.platform.LocalConfiguration
35 import androidx.compose.ui.res.stringResource
36 import androidx.compose.ui.unit.dp
37 import androidx.lifecycle.compose.collectAsStateWithLifecycle
38 import androidx.paging.LoadState
39 import androidx.paging.PagingData
40 import androidx.paging.compose.collectAsLazyPagingItems
41 import com.android.modules.utils.build.SdkLevel
42 import com.android.photopicker.R
43 import com.android.photopicker.core.components.EmptyState
44 import com.android.photopicker.core.components.MediaGridItem
45 import com.android.photopicker.core.components.mediaGrid
46 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
47 import com.android.photopicker.core.configuration.PhotopickerRuntimeEnv
48 import com.android.photopicker.core.embedded.LocalEmbeddedState
49 import com.android.photopicker.core.events.Event
50 import com.android.photopicker.core.events.LocalEvents
51 import com.android.photopicker.core.events.Telemetry
52 import com.android.photopicker.core.features.FeatureToken
53 import com.android.photopicker.core.features.LocalFeatureManager
54 import com.android.photopicker.core.navigation.LocalNavController
55 import com.android.photopicker.core.navigation.PhotopickerDestinations
56 import com.android.photopicker.core.obtainViewModel
57 import com.android.photopicker.core.selection.LocalSelection
58 import com.android.photopicker.core.theme.LocalWindowSizeClass
59 import com.android.photopicker.data.model.Group
60 import com.android.photopicker.extensions.navigateToPreviewMedia
61 import com.android.photopicker.features.preview.PreviewFeature
62 import kotlinx.coroutines.flow.Flow
63 import kotlinx.coroutines.flow.StateFlow
64 import kotlinx.coroutines.launch
65
66 /**
67 * Primary composable for drawing the Mediaset content grid on
68 * [PhotopickerDestinations.MEDIASET_CONTENT_GRID]
69 *
70 * @param viewModel - A viewModel override for the composable. Normally, this is fetched via hilt
71 * from the backstack entry by using obtainViewModel()
72 * @param flow - stateflow holding the mediaset for which the media needs to be presented.
73 */
74 @Composable
75 fun MediaSetContentGrid(
76 flow: StateFlow<Group.MediaSet?>,
77 viewModel: CategoryGridViewModel = obtainViewModel(),
78 ) {
79 val mediasetState by flow.collectAsStateWithLifecycle(initialValue = null)
80 val mediaset = mediasetState
81 Column(modifier = Modifier.fillMaxSize()) {
82 when (mediaset) {
83 null -> {}
84 else -> {
85 val mediasetItems = remember(mediaset) { viewModel.getMediaSetContent(mediaset) }
86 MediasetContentGrid(mediasetItems = mediasetItems)
87 }
88 }
89 }
90 }
91
92 /** Initialises all the states and media source required to load media for the input [mediaset]. */
93 @Composable
MediasetContentGridnull94 private fun MediasetContentGrid(
95 mediasetItems: Flow<PagingData<MediaGridItem>>,
96 viewModel: CategoryGridViewModel = obtainViewModel(),
97 ) {
98 val featureManager = LocalFeatureManager.current
99 val isPreviewEnabled = remember { featureManager.isFeatureEnabled(PreviewFeature::class.java) }
100 val navController = LocalNavController.current
101 val items = mediasetItems.collectAsLazyPagingItems()
102 // Collect the selection to notify the mediaGrid of selection changes.
103 val selection by LocalSelection.current.flow.collectAsStateWithLifecycle()
104 val selectionLimit = LocalPhotopickerConfiguration.current.selectionLimit
105 val selectionLimitExceededMessage =
106 stringResource(R.string.photopicker_selection_limit_exceeded_snackbar, selectionLimit)
107 // Use the expanded layout any time the Width is Medium or larger.
108 val isExpandedScreen: Boolean =
109 when (LocalWindowSizeClass.current.widthSizeClass) {
110 WindowWidthSizeClass.Medium -> true
111 WindowWidthSizeClass.Expanded -> true
112 else -> false
113 }
114 val state = rememberLazyGridState()
115 val isEmbedded =
116 LocalPhotopickerConfiguration.current.runtimeEnv == PhotopickerRuntimeEnv.EMBEDDED
117 val host = LocalEmbeddedState.current?.host
118 val scope = rememberCoroutineScope()
119 val events = LocalEvents.current
120 val configuration = LocalPhotopickerConfiguration.current
121 // Container encapsulating the mediaset title followed by its content in the form of a
122 // grid, the content also includes date and month separators.
123 Column(modifier = Modifier.fillMaxSize()) {
124 val isEmptyAndNoMorePages =
125 items.itemCount == 0 &&
126 items.loadState.source.append is LoadState.NotLoading &&
127 items.loadState.source.append.endOfPaginationReached
128 when {
129 isEmptyAndNoMorePages -> {
130 val localConfig = LocalConfiguration.current
131 val emptyStatePadding =
132 remember(localConfig) { (localConfig.screenHeightDp * .20).dp }
133 val (title, body, icon) = getEmptyStateContentForMediaset()
134 EmptyState(
135 modifier =
136 if (SdkLevel.isAtLeastU() && isEmbedded && host != null) {
137 // In embedded no need to give extra top padding to make empty
138 // state title and body clearly visible in collapse mode (small view)
139 Modifier.fillMaxWidth()
140 } else {
141 // Provide 20% of screen height as empty space above
142 Modifier.fillMaxWidth().padding(top = emptyStatePadding)
143 },
144 icon = icon,
145 title = title,
146 body = body,
147 )
148 LaunchedEffect(Unit) {
149 events.dispatch(
150 Event.LogPhotopickerUIEvent(
151 FeatureToken.CATEGORY_GRID.token,
152 configuration.sessionId,
153 configuration.callingPackageUid ?: -1,
154 Telemetry.UiEvent.UI_LOADED_EMPTY_STATE,
155 )
156 )
157 }
158 }
159 else -> {
160 mediaGrid(
161 items = items,
162 isExpandedScreen = isExpandedScreen,
163 selection = selection,
164 onItemClick = { item ->
165 if (item is MediaGridItem.MediaItem) {
166 viewModel.handleMediaSetItemSelection(
167 item.media,
168 selectionLimitExceededMessage,
169 )
170 }
171 },
172 onItemLongPress = { item ->
173 // If the [PreviewFeature] is enabled, launch the preview route.
174 if (isPreviewEnabled && item is MediaGridItem.MediaItem) {
175 // Dispatch UI event to log long pressing the media item
176 scope.launch {
177 events.dispatch(
178 Event.LogPhotopickerUIEvent(
179 FeatureToken.PREVIEW.token,
180 configuration.sessionId,
181 configuration.callingPackageUid ?: -1,
182 Telemetry.UiEvent.PICKER_LONG_SELECT_MEDIA_ITEM,
183 )
184 )
185 }
186 // Dispatch UI event to log entry into preview mode
187 scope.launch {
188 events.dispatch(
189 Event.LogPhotopickerUIEvent(
190 FeatureToken.PREVIEW.token,
191 configuration.sessionId,
192 configuration.callingPackageUid ?: -1,
193 Telemetry.UiEvent.ENTER_PICKER_PREVIEW_MODE,
194 )
195 )
196 }
197 navController.navigateToPreviewMedia(item.media)
198 }
199 },
200 state = state,
201 )
202 LaunchedEffect(Unit) {
203 // Dispatch UI event to log loading of meadia set contents
204 events.dispatch(
205 Event.LogPhotopickerUIEvent(
206 FeatureToken.CATEGORY_GRID.token,
207 configuration.sessionId,
208 configuration.callingPackageUid ?: -1,
209 Telemetry.UiEvent.UI_LOADED_MEDIA_SETS_CONTENTS,
210 )
211 )
212 }
213 }
214 }
215 }
216 }
217
218 /**
219 * Return a generic content for the empty state.
220 *
221 * @return a [Triple] that contains the [Title, Body, Icon] for the empty state.
222 */
223 @Composable
getEmptyStateContentForMediasetnull224 private fun getEmptyStateContentForMediaset(): Triple<String, String, ImageVector> {
225 return Triple(
226 stringResource(R.string.photopicker_photos_empty_state_title),
227 stringResource(R.string.photopicker_photos_empty_state_body),
228 Icons.Outlined.Image,
229 )
230 }
231