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