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.albumgrid 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.ALBUM_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.Group 34 import com.android.photopicker.data.model.Media 35 import com.android.photopicker.extensions.insertMonthSeparators 36 import com.android.photopicker.extensions.toMediaGridItemFromAlbum 37 import com.android.photopicker.extensions.toMediaGridItemFromMedia 38 import dagger.hilt.android.lifecycle.HiltViewModel 39 import javax.inject.Inject 40 import kotlinx.coroutines.CoroutineScope 41 import kotlinx.coroutines.flow.Flow 42 import kotlinx.coroutines.flow.MutableStateFlow 43 import kotlinx.coroutines.flow.StateFlow 44 import kotlinx.coroutines.launch 45 46 /** 47 * The view model for the primary album grid. 48 * 49 * This view model collects the data from [DataService] and caches it in its scope so that loaded 50 * data is saved between navigations so that the composable can maintain list positions when 51 * navigating back and forth between routes. 52 */ 53 @HiltViewModel 54 class AlbumGridViewModel 55 @Inject 56 constructor( 57 private val scopeOverride: CoroutineScope?, 58 private val selection: Selection<Media>, 59 private val dataService: DataService, 60 private val events: Events, 61 ) : ViewModel() { 62 // Check if a scope override was injected before using the default [viewModelScope] 63 private val scope: CoroutineScope = 64 if (scopeOverride == null) { 65 this.viewModelScope 66 } else { 67 scopeOverride 68 } 69 70 // Request Media in batches of 50 items 71 private val ALBUM_GRID_PAGE_SIZE = 50 72 73 // Keep up to 10 pages loaded in memory before unloading pages. 74 private val ALBUM_GRID_MAX_ITEMS_IN_MEMORY = ALBUM_GRID_PAGE_SIZE * 10 75 76 private val _previouslySelectedItem = MutableStateFlow<MediaGridItem?>(null) 77 val previouslySelectedItem: StateFlow<MediaGridItem?> = _previouslySelectedItem 78 79 /** 80 * Sets the previously selected album grid item. 81 * 82 * This function updates the [_previouslySelectedItem] with the provided item. The stored item 83 * is used to request focus to that album's cell in the album grid when the user navigates back 84 * from the album media grid. 85 * 86 * @param item The media grid item to store as the previously selected item, or null to reset 87 */ setPreviouslySelectedItemnull88 fun setPreviouslySelectedItem(item: MediaGridItem?) { 89 _previouslySelectedItem.value = item 90 } 91 92 /** 93 * Returns [PagingData] of type [MediaGridItem] as a [Flow] containing media for the album 94 * represented by [albumId]. 95 */ getAlbumMedianull96 fun getAlbumMedia(album: Group.Album): Flow<PagingData<MediaGridItem>> { 97 val pagerForAlbumMedia = 98 Pager( 99 PagingConfig( 100 pageSize = ALBUM_GRID_PAGE_SIZE, 101 maxSize = ALBUM_GRID_MAX_ITEMS_IN_MEMORY, 102 ) 103 ) { 104 // pagingSource 105 dataService.albumMediaPagingSource(album) 106 } 107 108 /** Export the data from the pager and prepare it for use in the [AlbumMediaGrid] */ 109 val albumMedia = 110 pagerForAlbumMedia.flow 111 .toMediaGridItemFromMedia() 112 .insertMonthSeparators() 113 // After the load and transformations, cache the data in the viewModelScope. 114 // This ensures that the list position and state will be remembered by the MediaGrid 115 // when navigating back to the AlbumGrid route. 116 .cachedIn(scope) 117 118 return albumMedia 119 } 120 121 /** 122 * Returns [PagingData] of type [MediaGridItem] as a [Flow] containing data for user's albums. 123 */ getAlbumsnull124 fun getAlbums(): Flow<PagingData<MediaGridItem>> { 125 val pagerForAlbums = 126 Pager( 127 PagingConfig( 128 pageSize = ALBUM_GRID_PAGE_SIZE, 129 maxSize = ALBUM_GRID_MAX_ITEMS_IN_MEMORY, 130 ) 131 ) { 132 dataService.albumPagingSource() 133 } 134 135 /** Export the data from the pager and prepare it for use in the [AlbumGrid] */ 136 val albums = 137 pagerForAlbums.flow 138 .toMediaGridItemFromAlbum() 139 // After the load and transformations, cache the data in the viewModelScope. 140 // This ensures that the list position and state will be remembered by the MediaGrid 141 // when navigating back to the AlbumGrid route. 142 .cachedIn(scope) 143 return albums 144 } 145 146 /** 147 * Click handler that is called when items in the grid are clicked. Selection updates are made 148 * in the viewModelScope to ensure they aren't cancelled if the user navigates away from the 149 * AlbumMediaGrid composable. 150 */ handleAlbumMediaGridItemSelectionnull151 fun handleAlbumMediaGridItemSelection( 152 item: Media, 153 selectionLimitExceededMessage: String, 154 album: Group.Album, 155 ) { 156 // Update the selectable values in the received media item. 157 val updatedMediaItem = 158 Media.withSelectable(item, /* selectionSource */ Telemetry.MediaLocation.ALBUM, album) 159 scope.launch { 160 val result = selection.toggle(updatedMediaItem) 161 if (result == FAILURE_SELECTION_LIMIT_EXCEEDED) { 162 events.dispatch( 163 Event.ShowSnackbarMessage(ALBUM_GRID.token, selectionLimitExceededMessage) 164 ) 165 } 166 } 167 } 168 } 169