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.photogrid
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.cachedIn
24 import com.android.photopicker.core.events.Event
25 import com.android.photopicker.core.events.Events
26 import com.android.photopicker.core.features.FeatureToken.PHOTO_GRID
27 import com.android.photopicker.core.selection.Selection
28 import com.android.photopicker.core.selection.SelectionModifiedResult.FAILURE_SELECTION_LIMIT_EXCEEDED
29 import com.android.photopicker.data.DataService
30 import com.android.photopicker.data.model.Media
31 import com.android.photopicker.extensions.insertMonthSeparators
32 import com.android.photopicker.extensions.toMediaGridItemFromMedia
33 import dagger.hilt.android.lifecycle.HiltViewModel
34 import javax.inject.Inject
35 import kotlinx.coroutines.CoroutineScope
36 import kotlinx.coroutines.launch
37 
38 /**
39  * The view model for the primary Photo grid.
40  *
41  * This view model collects the data from [DataService] and caches it in its scope so that loaded
42  * data is saved between navigations so that the composable can maintain list positions when
43  * navigating back and forth between routes.
44  */
45 @HiltViewModel
46 class PhotoGridViewModel
47 @Inject
48 constructor(
49     private val scopeOverride: CoroutineScope?,
50     private val selection: Selection<Media>,
51     private val dataService: DataService,
52     private val events: Events,
53 ) : ViewModel() {
54 
55     // Check if a scope override was injected before using the default [viewModelScope]
56     private val scope: CoroutineScope =
57         if (scopeOverride == null) {
58             this.viewModelScope
59         } else {
60             scopeOverride
61         }
62 
63     // Request Media in batches of 50 items
64     private val PHOTO_GRID_PAGE_SIZE = 50
65 
66     // The size of the initial load when no pages are loaded. Ensures there is enough content
67     // to cover small scrolls.
68     private val PHOTO_GRID_INITIAL_LOAD_SIZE = PHOTO_GRID_PAGE_SIZE * 3
69 
70     // How far from the edge of loaded content before fetching the next page
71     private val PHOTO_GRID_PREFETCH_DISTANCE = PHOTO_GRID_PAGE_SIZE * 2
72 
73     // Keep up to 10 pages loaded in memory before unloading pages.
74     private val PHOTO_GRID_MAX_ITEMS_IN_MEMORY = PHOTO_GRID_PAGE_SIZE * 10
75 
76     val pagingConfig =
77         PagingConfig(
78             pageSize = PHOTO_GRID_PAGE_SIZE,
79             maxSize = PHOTO_GRID_MAX_ITEMS_IN_MEMORY,
80             initialLoadSize = PHOTO_GRID_INITIAL_LOAD_SIZE,
81             prefetchDistance = PHOTO_GRID_PREFETCH_DISTANCE,
82         )
83 
84     val pager =
85         Pager(
86             PagingConfig(pageSize = PHOTO_GRID_PAGE_SIZE, maxSize = PHOTO_GRID_MAX_ITEMS_IN_MEMORY)
<lambda>null87         ) {
88             dataService.mediaPagingSource()
89         }
90 
91     /** Export the data from the pager and prepare it for use in the [MediaGrid] */
92     val data =
93         pager.flow
94             .toMediaGridItemFromMedia()
95             .insertMonthSeparators()
96             // After the load and transformations, cache the data in the viewModelScope.
97             // This ensures that the list position and state will be remembered by the MediaGrid
98             // when navigating back to the PhotoGrid route.
99             .cachedIn(scope)
100 
101     /**
102      * Click handler that is called when items in the grid are clicked. Selection updates are made
103      * in the viewModelScope to ensure they aren't canceled if the user navigates away from the
104      * PhotoGrid composable.
105      */
handleGridItemSelectionnull106     fun handleGridItemSelection(item: Media, selectionLimitExceededMessage: String) {
107         scope.launch {
108             val result = selection.toggle(item)
109             if (result == FAILURE_SELECTION_LIMIT_EXCEEDED) {
110                 scope.launch {
111                     events.dispatch(
112                         Event.ShowSnackbarMessage(PHOTO_GRID.token, selectionLimitExceededMessage)
113                     )
114                 }
115             }
116         }
117     }
118 }
119