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.gestures.detectHorizontalDragGestures
20 import androidx.compose.foundation.layout.Column
21 import androidx.compose.foundation.layout.PaddingValues
22 import androidx.compose.foundation.layout.Row
23 import androidx.compose.foundation.layout.Spacer
24 import androidx.compose.foundation.layout.fillMaxSize
25 import androidx.compose.foundation.layout.size
26 import androidx.compose.foundation.layout.width
27 import androidx.compose.foundation.lazy.grid.GridCells
28 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
29 import androidx.compose.material3.Icon
30 import androidx.compose.material3.Text
31 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
32 import androidx.compose.runtime.Composable
33 import androidx.compose.runtime.LaunchedEffect
34 import androidx.compose.runtime.getValue
35 import androidx.compose.runtime.rememberCoroutineScope
36 import androidx.compose.ui.Alignment
37 import androidx.compose.ui.Modifier
38 import androidx.compose.ui.graphics.vector.ImageVector
39 import androidx.compose.ui.input.pointer.pointerInput
40 import androidx.compose.ui.res.stringResource
41 import androidx.compose.ui.res.vectorResource
42 import androidx.compose.ui.semantics.contentDescription
43 import androidx.compose.ui.text.style.TextOverflow
44 import androidx.compose.ui.unit.dp
45 import androidx.lifecycle.compose.collectAsStateWithLifecycle
46 import androidx.paging.compose.collectAsLazyPagingItems
47 import com.android.photopicker.R
48 import com.android.photopicker.core.components.MediaGridItem
49 import com.android.photopicker.core.components.mediaGrid
50 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
51 import com.android.photopicker.core.events.Event
52 import com.android.photopicker.core.events.LocalEvents
53 import com.android.photopicker.core.events.Telemetry
54 import com.android.photopicker.core.features.FeatureToken
55 import com.android.photopicker.core.features.LocalFeatureManager
56 import com.android.photopicker.core.navigation.LocalNavController
57 import com.android.photopicker.core.navigation.PhotopickerDestinations
58 import com.android.photopicker.core.obtainViewModel
59 import com.android.photopicker.core.theme.LocalWindowSizeClass
60 import com.android.photopicker.extensions.navigateToAlbumMediaGridForCategories
61 import com.android.photopicker.extensions.navigateToCategoryGrid
62 import com.android.photopicker.extensions.navigateToMediaSetGrid
63 import com.android.photopicker.extensions.navigateToPhotoGrid
64 import com.android.photopicker.features.navigationbar.NavigationBarButton
65 import com.android.photopicker.features.photogrid.PhotoGridFeature
66 import com.android.photopicker.features.search.SearchFeature
67 import kotlinx.coroutines.launch
68
69 /** The number of grid cells per row for Phone / narrow layouts */
70 private val CELLS_PER_ROW_FOR_CATEGORY_GRID = 2
71
72 /** The number of grid cells per row for Tablet / expanded layouts */
73 private val CELLS_PER_ROW_EXPANDED_FOR_CATEGORY_GRID = 3
74
75 /** The amount of padding to use around each cell in the categories grid. */
76 private val MEASUREMENT_HORIZONTAL_CELL_SPACING_CATEGORY_GRID = 16.dp
77
78 /**
79 * Primary composable for drawing the main Category Grid on [PhotopickerDestinations.ALBUM_GRID]
80 *
81 * @param viewModel - A viewModel override for the composable. Normally, this is fetched via hilt
82 * from the backstack entry by using obtainViewModel()
83 */
84 @Composable
85 fun CategoryGrid(viewModel: CategoryGridViewModel = obtainViewModel()) {
86 val items = viewModel.getCategoriesAndAlbums().collectAsLazyPagingItems()
87 val state = rememberLazyGridState()
88 val navController = LocalNavController.current
89 val featureManager = LocalFeatureManager.current
90 val configuration = LocalPhotopickerConfiguration.current
91 val events = LocalEvents.current
92 val scope = rememberCoroutineScope()
93
94 // Use the expanded layout any time the Width is Medium or larger.
95 val isExpandedScreen: Boolean =
96 when (LocalWindowSizeClass.current.widthSizeClass) {
97 WindowWidthSizeClass.Medium -> true
98 WindowWidthSizeClass.Expanded -> true
99 else -> false
100 }
101
102 val previouslySelectedItem by viewModel.previouslySelectedItem.collectAsStateWithLifecycle()
103 Column(
104 modifier =
105 Modifier.fillMaxSize().pointerInput(Unit) {
106 detectHorizontalDragGestures(
107 onHorizontalDrag = { _, dragAmount ->
108 // This may need some additional fine tuning by looking at a certain
109 // distance in dragAmount, but initial testing suggested this worked
110 // pretty well as is.
111 if (dragAmount > 0) {
112 // Positive is a right swipe
113 if (featureManager.isFeatureEnabled(PhotoGridFeature::class.java)) {
114 navController.navigateToPhotoGrid()
115 // Dispatch UI event to indicate switching to photos tab
116 scope.launch {
117 events.dispatch(
118 Event.LogPhotopickerUIEvent(
119 FeatureToken.CATEGORY_GRID.token,
120 configuration.sessionId,
121 configuration.callingPackageUid ?: -1,
122 Telemetry.UiEvent.SWITCH_PICKER_TAB,
123 )
124 )
125 }
126 }
127 }
128 }
129 )
130 }
131 ) {
132 // Invoke the composable for Category Grid. OnClick uses the navController to navigate to
133 // the category content for the category that is selected by the user.
134 mediaGrid(
135 items = items,
136 focusItem = previouslySelectedItem,
137 onItemClick = { item ->
138 if (item is MediaGridItem.AlbumItem) {
139 // Dispatch events to log album related details
140 scope.launch {
141 events.dispatch(
142 Event.LogPhotopickerAlbumOpenedUIEvent(
143 FeatureToken.CATEGORY_GRID.token,
144 configuration.sessionId,
145 configuration.callingPackageUid ?: -1,
146 item.album,
147 )
148 )
149 events.dispatch(
150 Event.LogPhotopickerUIEvent(
151 FeatureToken.CATEGORY_GRID.token,
152 configuration.sessionId,
153 configuration.callingPackageUid ?: -1,
154 Telemetry.UiEvent.PICKER_ALBUMS_INTERACTION,
155 )
156 )
157 }
158 viewModel.setPreviouslySelectedItem(item)
159 navController.navigateToAlbumMediaGridForCategories(album = item.album)
160 } else if (item is MediaGridItem.CategoryItem) {
161 scope.launch {
162 events.dispatch(
163 Event.LogPhotopickerUIEvent(
164 FeatureToken.CATEGORY_GRID.token,
165 configuration.sessionId,
166 configuration.callingPackageUid ?: -1,
167 Telemetry.UiEvent.CATEGORY_MEDIA_SETS_OPEN,
168 )
169 )
170 }
171 viewModel.setPreviouslySelectedItem(item)
172 navController.navigateToMediaSetGrid(category = item.category)
173 }
174 },
175 isExpandedScreen = isExpandedScreen,
176 columns =
177 when (isExpandedScreen) {
178 true -> GridCells.Fixed(CELLS_PER_ROW_EXPANDED_FOR_CATEGORY_GRID)
179 false -> GridCells.Fixed(CELLS_PER_ROW_FOR_CATEGORY_GRID)
180 },
181 selection = emptySet(),
182 gridCellPadding = MEASUREMENT_HORIZONTAL_CELL_SPACING_CATEGORY_GRID,
183 contentPadding = PaddingValues(MEASUREMENT_HORIZONTAL_CELL_SPACING_CATEGORY_GRID),
184 state = state,
185 )
186 LaunchedEffect(Unit) {
187 // Dispatch UI event to denote loading of media categories and albums
188 scope.launch {
189 events.dispatch(
190 Event.LogPhotopickerUIEvent(
191 FeatureToken.CATEGORY_GRID.token,
192 configuration.sessionId,
193 configuration.callingPackageUid ?: -1,
194 Telemetry.UiEvent.UI_LOADED_CATEGORIES_AND_ALBUMS,
195 )
196 )
197 }
198 }
199 }
200 }
201
202 /**
203 * The navigation button for the main category grid. Composable for
204 * [Location.NAVIGATION_BAR_NAV_BUTTON]
205 */
206 @Composable
CategoryButtonnull207 fun CategoryButton(modifier: Modifier) {
208 val navController = LocalNavController.current
209 val scope = rememberCoroutineScope()
210 val events = LocalEvents.current
211 val sessionId = LocalPhotopickerConfiguration.current.sessionId
212 val packageUid = LocalPhotopickerConfiguration.current.callingPackageUid ?: -1
213 val featureManager = LocalFeatureManager.current
214 val searchFeatureEnabled = featureManager.isFeatureEnabled(SearchFeature::class.java)
215
216 NavigationBarButton(
217 onClick = {
218 // Dispatch UI event to denote switching to category tab
219 scope.launch {
220 events.dispatch(
221 Event.LogPhotopickerUIEvent(
222 FeatureToken.CATEGORY_GRID.token,
223 sessionId,
224 packageUid,
225 Telemetry.UiEvent.SWITCH_PICKER_TAB,
226 )
227 )
228 }
229 navController.navigateToCategoryGrid()
230 },
231 modifier = modifier,
232 isCurrentRoute = { route -> route == PhotopickerDestinations.ALBUM_GRID.route },
233 ) {
234 when {
235 searchFeatureEnabled -> {
236 Row(verticalAlignment = Alignment.CenterVertically) {
237 Icon(
238 imageVector =
239 ImageVector.vectorResource(R.drawable.photopicker_category_icon),
240 contentDescription = null,
241 modifier = Modifier.size(18.dp),
242 )
243 Spacer(Modifier.width(8.dp))
244 Text(
245 stringResource(R.string.photopicker_categories_nav_button_label),
246 maxLines = 1, // Limit the text to a single line
247 overflow = TextOverflow.Ellipsis,
248 )
249 }
250 }
251 else ->
252 Text(
253 stringResource(R.string.photopicker_categories_nav_button_label),
254 maxLines = 1, // Limit the text to a single line
255 overflow = TextOverflow.Ellipsis,
256 )
257 }
258 }
259 }
260