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.PaddingValues
21 import androidx.compose.foundation.layout.fillMaxSize
22 import androidx.compose.foundation.layout.fillMaxWidth
23 import androidx.compose.foundation.layout.padding
24 import androidx.compose.foundation.lazy.grid.GridCells
25 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
26 import androidx.compose.material.icons.Icons
27 import androidx.compose.material.icons.outlined.Group
28 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
29 import androidx.compose.runtime.Composable
30 import androidx.compose.runtime.LaunchedEffect
31 import androidx.compose.runtime.getValue
32 import androidx.compose.runtime.remember
33 import androidx.compose.runtime.rememberCoroutineScope
34 import androidx.compose.ui.Modifier
35 import androidx.compose.ui.graphics.vector.ImageVector
36 import androidx.compose.ui.platform.LocalConfiguration
37 import androidx.compose.ui.res.stringResource
38 import androidx.compose.ui.unit.dp
39 import androidx.lifecycle.compose.collectAsStateWithLifecycle
40 import androidx.paging.LoadState
41 import androidx.paging.compose.collectAsLazyPagingItems
42 import com.android.modules.utils.build.SdkLevel
43 import com.android.photopicker.R
44 import com.android.photopicker.core.components.EmptyState
45 import com.android.photopicker.core.components.MediaGridItem
46 import com.android.photopicker.core.components.mediaGrid
47 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
48 import com.android.photopicker.core.configuration.PhotopickerRuntimeEnv
49 import com.android.photopicker.core.embedded.LocalEmbeddedState
50 import com.android.photopicker.core.events.Event
51 import com.android.photopicker.core.events.LocalEvents
52 import com.android.photopicker.core.events.Telemetry
53 import com.android.photopicker.core.features.FeatureToken
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.theme.LocalWindowSizeClass
58 import com.android.photopicker.data.model.CategoryType
59 import com.android.photopicker.data.model.Group
60 import com.android.photopicker.extensions.navigateToMediaSetContentGrid
61 import kotlinx.coroutines.flow.StateFlow
62 import kotlinx.coroutines.launch
63
64 /** The number of grid cells per row for Phone / narrow layouts */
65 private val CELLS_PER_ROW_FOR_MEDIASET_GRID = 3
66 /** The number of grid cells per row for Tablet / expanded layouts */
67 private val CELLS_PER_ROW_EXPANDED_FOR_MEDIASET_GRID = 4
68 /** The amount of padding to use around each cell in the mediaset grid. */
69 private val MEASUREMENT_HORIZONTAL_CELL_SPACING_MEDIASET_GRID = 1.dp
70
71 /**
72 * Primary composable for drawing the main MediasetGrid on [PhotopickerDestinations.MEDIASET_GRID]
73 *
74 * @param viewModel - A viewModel override for the composable. Normally, this is fetched via hilt
75 * from the backstack entry by using obtainViewModel()
76 */
77 @Composable
78 fun MediaSetGrid(
79 flow: StateFlow<Group.Category?>,
80 viewModel: CategoryGridViewModel = obtainViewModel(),
81 ) {
82 val categoryState by flow.collectAsStateWithLifecycle(initialValue = null)
83 val category = categoryState
84 when (category) {
85 null -> {}
86 else -> {
87 val items = remember(category) { viewModel.getMediaSets(category) }
88 val state = rememberLazyGridState()
89 val navController = LocalNavController.current
90 val scope = rememberCoroutineScope()
91 val events = LocalEvents.current
92 val configuration = LocalPhotopickerConfiguration.current
93
94 val isEmbedded =
95 LocalPhotopickerConfiguration.current.runtimeEnv == PhotopickerRuntimeEnv.EMBEDDED
96 val host = LocalEmbeddedState.current?.host
97 // Use the expanded layout any time the Width is Medium or larger.
98 val isExpandedScreen: Boolean =
99 when (LocalWindowSizeClass.current.widthSizeClass) {
100 WindowWidthSizeClass.Medium -> true
101 WindowWidthSizeClass.Expanded -> true
102 else -> false
103 }
104 val mediaSetItems = items.collectAsLazyPagingItems()
105 Column(modifier = Modifier.fillMaxSize()) {
106 val isEmptyAndNoMorePages =
107 mediaSetItems.itemCount == 0 &&
108 mediaSetItems.loadState.source.append is LoadState.NotLoading &&
109 mediaSetItems.loadState.source.append.endOfPaginationReached
110 when {
111 isEmptyAndNoMorePages -> {
112 val localConfig = LocalConfiguration.current
113 val emptyStatePadding =
114 remember(localConfig) { (localConfig.screenHeightDp * .20).dp }
115 val (title, body, icon) =
116 getEmptyStateContentForMediaset(category.categoryType)
117 EmptyState(
118 modifier =
119 if (SdkLevel.isAtLeastU() && isEmbedded && host != null) {
120 // In embedded no need to give extra top padding to make empty
121 // state title and body clearly visible in collapse mode (small
122 // view)
123 Modifier.fillMaxWidth()
124 } else {
125 // Provide 20% of screen height as empty space above
126 Modifier.fillMaxWidth().padding(top = emptyStatePadding)
127 },
128 icon = icon,
129 title = title,
130 body = body,
131 )
132 LaunchedEffect(Unit) {
133 events.dispatch(
134 Event.LogPhotopickerUIEvent(
135 FeatureToken.CATEGORY_GRID.token,
136 configuration.sessionId,
137 configuration.callingPackageUid ?: -1,
138 Telemetry.UiEvent.UI_LOADED_EMPTY_STATE,
139 )
140 )
141 }
142 }
143
144 else -> {
145 // Invoke the composable for MediasetGrid. OnClick uses the navController to
146 // navigate to the mediaset content for the mediaset that is selected by the
147 // user.
148 mediaGrid(
149 items = mediaSetItems,
150 onItemClick = { item ->
151 if (item is MediaGridItem.PersonMediaSetItem) {
152 scope.launch {
153 events.dispatch(
154 Event.LogPhotopickerUIEvent(
155 FeatureToken.CATEGORY_GRID.token,
156 configuration.sessionId,
157 configuration.callingPackageUid ?: -1,
158 Telemetry.UiEvent.CATEGORY_PEOPLEPET_OPEN,
159 )
160 )
161 }
162 navController.navigateToMediaSetContentGrid(
163 mediaSet = item.mediaSet
164 )
165 } else if (item is MediaGridItem.MediaSetItem) {
166 scope.launch {
167 events.dispatch(
168 Event.LogPhotopickerUIEvent(
169 FeatureToken.CATEGORY_GRID.token,
170 configuration.sessionId,
171 configuration.callingPackageUid ?: -1,
172 Telemetry.UiEvent.PICKER_CATEGORIES_INTERACTION,
173 )
174 )
175 }
176 navController.navigateToMediaSetContentGrid(
177 mediaSet = item.mediaSet
178 )
179 }
180 },
181 isExpandedScreen = isExpandedScreen,
182 columns =
183 when (isExpandedScreen) {
184 true ->
185 GridCells.Fixed(CELLS_PER_ROW_EXPANDED_FOR_MEDIASET_GRID)
186 false -> GridCells.Fixed(CELLS_PER_ROW_FOR_MEDIASET_GRID)
187 },
188 selection = emptySet(),
189 gridCellPadding = MEASUREMENT_HORIZONTAL_CELL_SPACING_MEDIASET_GRID,
190 contentPadding =
191 PaddingValues(MEASUREMENT_HORIZONTAL_CELL_SPACING_MEDIASET_GRID),
192 state = state,
193 )
194 LaunchedEffect(Unit) {
195 // Dispatch UI event to log loading of category contents
196 events.dispatch(
197 Event.LogPhotopickerUIEvent(
198 FeatureToken.CATEGORY_GRID.token,
199 configuration.sessionId,
200 configuration.callingPackageUid ?: -1,
201 Telemetry.UiEvent.UI_LOADED_MEDIA_SETS,
202 )
203 )
204 }
205 }
206 }
207 }
208 }
209 }
210 }
211
212 /**
213 * Return a generic content for the empty state.
214 *
215 * @return a [Triple] that contains the [Title, Body, Icon] for the empty state.
216 */
217 @Composable
getEmptyStateContentForMediasetnull218 private fun getEmptyStateContentForMediaset(
219 categoryType: CategoryType
220 ): Triple<String, String, ImageVector> {
221 return if (categoryType == CategoryType.PEOPLE_AND_PETS) {
222 Triple(
223 stringResource(R.string.photopicker_people_category_empty_state_title),
224 stringResource(R.string.photopicker_people_category_empty_state_body),
225 Icons.Outlined.Group,
226 )
227 } else {
228 Triple(
229 stringResource(R.string.photopicker_photos_empty_state_title),
230 stringResource(R.string.photopicker_photos_empty_state_body),
231 Icons.Outlined.Group,
232 )
233 }
234 }
235