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 package com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel
17
18 import android.util.Size
19 import com.android.intentresolver.Flags
20 import com.android.intentresolver.Flags.unselectFinalItem
21 import com.android.intentresolver.contentpreview.CachingImagePreviewImageLoader
22 import com.android.intentresolver.contentpreview.HeadlineGenerator
23 import com.android.intentresolver.contentpreview.ImageLoader
24 import com.android.intentresolver.contentpreview.MimeTypeClassifier
25 import com.android.intentresolver.contentpreview.PreviewImageLoader
26 import com.android.intentresolver.contentpreview.payloadtoggle.domain.cursor.PayloadToggle
27 import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.ChooserRequestInteractor
28 import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.CustomActionsInteractor
29 import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectablePreviewsInteractor
30 import com.android.intentresolver.contentpreview.payloadtoggle.domain.interactor.SelectionInteractor
31 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
32 import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
33 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
34 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
35 import com.android.intentresolver.inject.ViewModelOwned
36 import dagger.Module
37 import dagger.Provides
38 import dagger.hilt.InstallIn
39 import dagger.hilt.android.components.ViewModelComponent
40 import javax.inject.Provider
41 import kotlinx.coroutines.CoroutineScope
42 import kotlinx.coroutines.flow.Flow
43 import kotlinx.coroutines.flow.SharingStarted
44 import kotlinx.coroutines.flow.distinctUntilChanged
45 import kotlinx.coroutines.flow.flow
46 import kotlinx.coroutines.flow.map
47 import kotlinx.coroutines.flow.stateIn
48 import kotlinx.coroutines.flow.zip
49
50 /** A dynamic carousel of selectable previews within share sheet. */
51 data class ShareouselViewModel(
52 /** Text displayed at the top of the share sheet when Shareousel is present. */
53 val headline: Flow<String>,
54 /** App-provided text shown beneath the headline. */
55 val metadataText: Flow<CharSequence?>,
56 /**
57 * Previews which are available for presentation within Shareousel. Use [preview] to create a
58 * [ShareouselPreviewViewModel] for a given [PreviewModel].
59 */
60 val previews: Flow<PreviewsModel?>,
61 /** List of action chips presented underneath Shareousel. */
62 val actions: Flow<List<ActionChipViewModel>>,
63 /** Indicates whether there are any selected items */
64 val hasSelectedItems: Flow<Boolean>,
65 /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */
66 val preview:
67 (
68 key: PreviewModel, previewHeight: Int, index: Int?, scope: CoroutineScope
69 ) -> ShareouselPreviewViewModel,
70 )
71
72 @Module
73 @InstallIn(ViewModelComponent::class)
74 object ShareouselViewModelModule {
75
76 @Provides
77 @PayloadToggle
78 fun imageLoader(
79 cachingImageLoader: Provider<CachingImagePreviewImageLoader>,
80 previewImageLoader: Provider<PreviewImageLoader>
81 ): ImageLoader =
82 if (Flags.previewImageLoader()) {
83 previewImageLoader.get()
84 } else {
85 cachingImageLoader.get()
86 }
87
88 @Provides
89 fun create(
90 interactor: SelectablePreviewsInteractor,
91 @PayloadToggle imageLoader: ImageLoader,
92 actionsInteractor: CustomActionsInteractor,
93 headlineGenerator: HeadlineGenerator,
94 selectionInteractor: SelectionInteractor,
95 chooserRequestInteractor: ChooserRequestInteractor,
96 mimeTypeClassifier: MimeTypeClassifier,
97 // TODO: remove if possible
98 @ViewModelOwned scope: CoroutineScope,
99 ): ShareouselViewModel {
100 val keySet =
101 interactor.previews.stateIn(
102 scope,
103 SharingStarted.Eagerly,
104 initialValue = null,
105 )
106 return ShareouselViewModel(
107 headline =
108 selectionInteractor.aggregateContentType.zip(selectionInteractor.amountSelected) {
109 contentType,
110 numItems ->
111 if (unselectFinalItem() && numItems == 0) {
112 headlineGenerator.getNotItemsSelectedHeadline()
113 } else {
114 when (contentType) {
115 ContentType.Other -> headlineGenerator.getFilesHeadline(numItems)
116 ContentType.Image -> headlineGenerator.getImagesHeadline(numItems)
117 ContentType.Video -> headlineGenerator.getVideosHeadline(numItems)
118 }
119 }
120 },
121 metadataText = chooserRequestInteractor.metadataText,
122 previews = keySet,
123 actions =
124 actionsInteractor.customActions.map { actions ->
125 actions.mapIndexedNotNull { i, model ->
126 val icon = model.icon
127 val label = model.label
128 if (icon == null && label.isBlank()) {
129 null
130 } else {
131 ActionChipViewModel(
132 label = label.toString(),
133 icon = model.icon,
134 onClicked = { model.performAction(i) },
135 )
136 }
137 }
138 },
139 hasSelectedItems =
140 selectionInteractor.selections.map { it.isNotEmpty() }.distinctUntilChanged(),
141 preview = { key, previewHeight, index, previewScope ->
142 keySet.value?.maybeLoad(index)
143 val previewInteractor = interactor.preview(key)
144 val contentType =
145 when {
146 mimeTypeClassifier.isImageType(key.mimeType) -> ContentType.Image
147 mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video
148 else -> ContentType.Other
149 }
150 val initialBitmapValue =
151 key.previewUri?.let {
152 imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) }
153 } ?: ValueUpdate.Absent
154 ShareouselPreviewViewModel(
155 bitmapLoadState =
156 flow {
157 val previewWidth =
158 if (key.aspectRatio > 0) {
159 previewHeight.toFloat() / key.aspectRatio
160 } else {
161 previewHeight
162 }
163 .toInt()
164 emit(
165 key.previewUri?.let {
166 ValueUpdate.Value(
167 imageLoader(it, Size(previewWidth, previewHeight))
168 )
169 } ?: ValueUpdate.Absent
170 )
171 }
172 .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue),
173 contentType = contentType,
174 isSelected = previewInteractor.isSelected,
175 setSelected = previewInteractor::setSelected,
176 aspectRatio = key.aspectRatio,
177 )
178 },
179 )
180 }
181 }
182
PreviewsModelnull183 private fun PreviewsModel.maybeLoad(index: Int?) {
184 when {
185 index == null -> {}
186 index <= leftTriggerIndex -> loadMoreLeft?.invoke()
187 index >= rightTriggerIndex -> loadMoreRight?.invoke()
188 }
189 }
190