• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * 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.categorygrid
18 
19 import androidx.lifecycle.ViewModel
20 import androidx.lifecycle.viewModelScope
21 import androidx.paging.Pager
22 import androidx.paging.PagingConfig
23 import androidx.paging.PagingData
24 import androidx.paging.cachedIn
25 import com.android.photopicker.core.components.MediaGridItem
26 import com.android.photopicker.core.events.Event
27 import com.android.photopicker.core.events.Events
28 import com.android.photopicker.core.events.Telemetry
29 import com.android.photopicker.core.features.FeatureToken.CATEGORY_GRID
30 import com.android.photopicker.core.selection.Selection
31 import com.android.photopicker.core.selection.SelectionModifiedResult.FAILURE_SELECTION_LIMIT_EXCEEDED
32 import com.android.photopicker.data.DataService
33 import com.android.photopicker.data.model.CategoryType
34 import com.android.photopicker.data.model.Group
35 import com.android.photopicker.data.model.Media
36 import com.android.photopicker.extensions.insertMonthSeparators
37 import com.android.photopicker.extensions.toMediaGridItemFromCategory
38 import com.android.photopicker.extensions.toMediaGridItemFromMedia
39 import com.android.photopicker.extensions.toMediaGridItemFromMediaSet
40 import com.android.photopicker.extensions.toMediaGridItemFromPeopleMediaSet
41 import com.android.photopicker.features.categorygrid.data.CategoryDataService
42 import dagger.hilt.android.lifecycle.HiltViewModel
43 import javax.inject.Inject
44 import kotlinx.coroutines.CoroutineScope
45 import kotlinx.coroutines.flow.Flow
46 import kotlinx.coroutines.flow.MutableStateFlow
47 import kotlinx.coroutines.flow.StateFlow
48 import kotlinx.coroutines.launch
49 
50 /**
51  * The view model for the primary album grid.
52  *
53  * This view model collects the data from [DataService] and caches it in its scope so that loaded
54  * data is saved between navigations so that the composable can maintain list positions when
55  * navigating back and forth between routes.
56  */
57 @HiltViewModel
58 class CategoryGridViewModel
59 @Inject
60 constructor(
61     private val scopeOverride: CoroutineScope?,
62     private val selection: Selection<Media>,
63     private val categoryDataService: CategoryDataService,
64     private val dataService: DataService,
65     private val events: Events,
66 ) : ViewModel() {
67     // Check if a scope override was injected before using the default [viewModelScope]
68     private val scope: CoroutineScope =
69         if (scopeOverride == null) {
70             this.viewModelScope
71         } else {
72             scopeOverride
73         }
74 
75     // Request Media in batches of 50 items
76     private val CATEGORY_GRID_PAGE_SIZE = 50
77 
78     // Keep up to 10 pages loaded in memory before unloading pages.
79     private val CATEGORY_GRID_MAX_ITEMS_IN_MEMORY = CATEGORY_GRID_PAGE_SIZE * 10
80 
81     private val _previouslySelectedItem = MutableStateFlow<MediaGridItem?>(null)
82     val previouslySelectedItem: StateFlow<MediaGridItem?> = _previouslySelectedItem
83 
84     /**
85      * Sets the previously selected category grid item.
86      *
87      * This function updates the [_previouslySelectedItem] with the provided item. The stored item
88      * is used to request focus to that album's cell in the album grid when the user navigates back
89      * from the album media grid.
90      *
91      * @param item The media grid item to store as the previously selected item, or null to reset
92      */
setPreviouslySelectedItemnull93     fun setPreviouslySelectedItem(item: MediaGridItem?) {
94         _previouslySelectedItem.value = item
95     }
96 
97     /**
98      * Returns [PagingData] of type [MediaGridItem] as a [Flow] containing media for the album
99      * represented by [albumId].
100      */
getAlbumMedianull101     fun getAlbumMedia(album: Group.Album): Flow<PagingData<MediaGridItem>> {
102         val pagerForAlbumMedia =
103             Pager(
104                 PagingConfig(
105                     pageSize = CATEGORY_GRID_PAGE_SIZE,
106                     maxSize = CATEGORY_GRID_MAX_ITEMS_IN_MEMORY,
107                 )
108             ) {
109                 // pagingSource
110                 dataService.albumMediaPagingSource(album)
111             }
112 
113         /** Export the data from the pager and prepare it for use in the [AlbumMediaGrid] */
114         val albumMedia =
115             pagerForAlbumMedia.flow
116                 .toMediaGridItemFromMedia()
117                 .insertMonthSeparators()
118                 // After the load and transformations, cache the data in the viewModelScope.
119                 // This ensures that the list position and state will be remembered by the MediaGrid
120                 // when navigating back to the AlbumGrid route.
121                 .cachedIn(scope)
122 
123         return albumMedia
124     }
125 
126     /**
127      * Returns [PagingData] of type [MediaGridItem] as a [Flow] containing data for user's albums.
128      */
getCategoriesAndAlbumsnull129     fun getCategoriesAndAlbums(category: Group.Category? = null): Flow<PagingData<MediaGridItem>> {
130         val pagerForCategories =
131             Pager(
132                 PagingConfig(
133                     pageSize = CATEGORY_GRID_PAGE_SIZE,
134                     maxSize = CATEGORY_GRID_MAX_ITEMS_IN_MEMORY,
135                 )
136             ) {
137                 when {
138                     category != null -> {
139                         categoryDataService.getCategories(parentCategory = category)
140                     }
141                     else -> {
142                         categoryDataService.getCategories()
143                     }
144                 }
145             }
146 
147         /** Export the data from the pager and prepare it for use in the [CategoryGrid] */
148         val group =
149             pagerForCategories.flow
150                 .toMediaGridItemFromCategory(category)
151                 // After the load and transformations, cache the data in the viewModelScope.
152                 // This ensures that the list position and state will be remembered by the MediaGrid
153                 // when navigating back to the AlbumGrid route.
154                 .cachedIn(scope)
155         return group
156     }
157 
158     /**
159      * Returns [PagingData] of type [MediaGridItem] as a [Flow] containing media for the mediaset
160      * represented by [mediaset].
161      */
getMediaSetContentnull162     fun getMediaSetContent(mediaset: Group.MediaSet): Flow<PagingData<MediaGridItem>> {
163         val pagerForMediaSetContents =
164             Pager(
165                 PagingConfig(
166                     pageSize = CATEGORY_GRID_PAGE_SIZE,
167                     maxSize = CATEGORY_GRID_MAX_ITEMS_IN_MEMORY,
168                 )
169             ) {
170                 categoryDataService.getMediaSetContents(mediaset)
171             }
172 
173         return pagerForMediaSetContents.flow
174             .toMediaGridItemFromMedia()
175             .insertMonthSeparators()
176             // After the load and transformations, cache the data in the viewModelScope.
177             // This ensures that the list position and state will be remembered by the MediaGrid
178             // when navigating back to the AlbumGrid route.
179             .cachedIn(scope)
180     }
181 
182     /**
183      * Returns [PagingData] of type [MediaGridItem] as a [Flow] containing media for the category
184      * represented by [categoryId].
185      */
getMediaSetsnull186     fun getMediaSets(category: Group.Category): Flow<PagingData<MediaGridItem>> {
187         val pagerForMediaSets =
188             Pager(
189                 PagingConfig(
190                     pageSize = CATEGORY_GRID_PAGE_SIZE,
191                     maxSize = CATEGORY_GRID_MAX_ITEMS_IN_MEMORY,
192                 )
193             ) {
194                 categoryDataService.getMediaSets(category)
195             }
196 
197         /** Export the data from the pager and prepare it for use in the [CategoryGrid] */
198         val mediaSets =
199             if (category.categoryType == CategoryType.PEOPLE_AND_PETS) {
200                 pagerForMediaSets.flow
201                     .toMediaGridItemFromPeopleMediaSet()
202                     // data should always be cached after all transformations are applied
203                     .cachedIn(scope)
204             } else {
205                 pagerForMediaSets.flow
206                     .toMediaGridItemFromMediaSet()
207                     // data should always be cached after all transformations are applied
208                     .cachedIn(scope)
209             }
210         return mediaSets
211     }
212 
213     /**
214      * Click handler that is called when items in the grid are clicked. Selection updates are made
215      * in the viewModelScope to ensure they aren't cancelled if the user navigates away from the
216      * AlbumMediaGrid composable.
217      */
handleAlbumMediaGridItemSelectionnull218     fun handleAlbumMediaGridItemSelection(
219         item: Media,
220         selectionLimitExceededMessage: String,
221         album: Group.Album,
222     ) {
223         // Update the selectable values in the received media item.
224         val updatedMediaItem =
225             Media.withSelectable(item, /* selectionSource */ Telemetry.MediaLocation.ALBUM, album)
226         scope.launch {
227             val result = selection.toggle(updatedMediaItem)
228             if (result == FAILURE_SELECTION_LIMIT_EXCEEDED) {
229                 events.dispatch(
230                     Event.ShowSnackbarMessage(CATEGORY_GRID.token, selectionLimitExceededMessage)
231                 )
232             }
233         }
234     }
235 
handleMediaSetItemSelectionnull236     fun handleMediaSetItemSelection(item: Media, selectionLimitExceededMessage: String) {
237         // Update the selectable values in the received media item.
238         val updatedMediaItem =
239             Media.withSelectable(item, /* selectionSource */ Telemetry.MediaLocation.CATEGORY, null)
240         scope.launch {
241             val result = selection.toggle(updatedMediaItem)
242             if (result == FAILURE_SELECTION_LIMIT_EXCEEDED) {
243                 events.dispatch(
244                     Event.ShowSnackbarMessage(CATEGORY_GRID.token, selectionLimitExceededMessage)
245                 )
246             }
247         }
248     }
249 }
250