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.composable
17 
18 import androidx.compose.animation.Crossfade
19 import androidx.compose.foundation.ExperimentalFoundationApi
20 import androidx.compose.foundation.Image
21 import androidx.compose.foundation.background
22 import androidx.compose.foundation.border
23 import androidx.compose.foundation.layout.Arrangement
24 import androidx.compose.foundation.layout.Box
25 import androidx.compose.foundation.layout.Column
26 import androidx.compose.foundation.layout.PaddingValues
27 import androidx.compose.foundation.layout.Spacer
28 import androidx.compose.foundation.layout.aspectRatio
29 import androidx.compose.foundation.layout.fillMaxHeight
30 import androidx.compose.foundation.layout.fillMaxSize
31 import androidx.compose.foundation.layout.fillMaxWidth
32 import androidx.compose.foundation.layout.height
33 import androidx.compose.foundation.layout.padding
34 import androidx.compose.foundation.layout.size
35 import androidx.compose.foundation.layout.width
36 import androidx.compose.foundation.lazy.LazyListState
37 import androidx.compose.foundation.lazy.LazyRow
38 import androidx.compose.foundation.lazy.itemsIndexed
39 import androidx.compose.foundation.selection.toggleable
40 import androidx.compose.foundation.shape.RoundedCornerShape
41 import androidx.compose.foundation.systemGestureExclusion
42 import androidx.compose.material3.AssistChip
43 import androidx.compose.material3.AssistChipDefaults
44 import androidx.compose.material3.LocalContentColor
45 import androidx.compose.material3.MaterialTheme
46 import androidx.compose.material3.Text
47 import androidx.compose.runtime.Composable
48 import androidx.compose.runtime.LaunchedEffect
49 import androidx.compose.runtime.derivedStateOf
50 import androidx.compose.runtime.getValue
51 import androidx.compose.runtime.mutableStateOf
52 import androidx.compose.runtime.remember
53 import androidx.compose.runtime.rememberCoroutineScope
54 import androidx.compose.runtime.setValue
55 import androidx.compose.ui.Modifier
56 import androidx.compose.ui.draw.clip
57 import androidx.compose.ui.graphics.ColorFilter
58 import androidx.compose.ui.graphics.asImageBitmap
59 import androidx.compose.ui.layout.ContentScale
60 import androidx.compose.ui.layout.MeasureScope
61 import androidx.compose.ui.layout.Placeable
62 import androidx.compose.ui.layout.layout
63 import androidx.compose.ui.res.dimensionResource
64 import androidx.compose.ui.res.stringResource
65 import androidx.compose.ui.semantics.contentDescription
66 import androidx.compose.ui.semantics.semantics
67 import androidx.compose.ui.unit.Dp
68 import androidx.compose.ui.unit.dp
69 import androidx.lifecycle.compose.collectAsStateWithLifecycle
70 import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections
71 import com.android.intentresolver.Flags.unselectFinalItem
72 import com.android.intentresolver.R
73 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
74 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault
75 import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
76 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
77 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
78 import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel
79 import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel
80 import kotlin.math.abs
81 import kotlin.math.min
82 import kotlin.math.roundToInt
83 import kotlinx.coroutines.flow.MutableStateFlow
84 import kotlinx.coroutines.launch
85 
86 @Composable
87 fun Shareousel(viewModel: ShareouselViewModel) {
88     val keySet = viewModel.previews.collectAsStateWithLifecycle(null).value
89     if (keySet != null) {
90         Shareousel(viewModel, keySet)
91     } else {
92         Spacer(
93             Modifier.height(dimensionResource(R.dimen.chooser_preview_image_height_tall) + 64.dp)
94                 .background(MaterialTheme.colorScheme.surfaceContainer)
95         )
96     }
97 }
98 
99 @Composable
Shareouselnull100 private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) {
101     Column(
102         modifier =
103             Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
104                 .padding(vertical = 16.dp)
105     ) {
106         PreviewCarousel(keySet, viewModel)
107         ActionCarousel(viewModel)
108     }
109 }
110 
111 @OptIn(ExperimentalFoundationApi::class)
112 @Composable
PreviewCarouselnull113 private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewModel) {
114     var measurements by remember { mutableStateOf(PreviewCarouselMeasurements.UNMEASURED) }
115     Box(
116         modifier =
117             Modifier.fillMaxWidth()
118                 .height(dimensionResource(R.dimen.chooser_preview_image_height_tall))
119                 .layout { measurable, constraints ->
120                     val placeable = measurable.measure(constraints)
121                     measurements =
122                         if (placeable.height <= 0) {
123                             PreviewCarouselMeasurements.UNMEASURED
124                         } else {
125                             PreviewCarouselMeasurements(placeable, measureScope = this)
126                         }
127                     layout(placeable.width, placeable.height) { placeable.place(0, 0) }
128                 }
129     ) {
130         // Do not compose the list until we have measured values
131         if (measurements == PreviewCarouselMeasurements.UNMEASURED) return@Box
132 
133         val prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }
134         val carouselState = remember {
135             LazyListState(
136                 prefetchStrategy = prefetchStrategy,
137                 firstVisibleItemIndex = previews.startIdx,
138                 firstVisibleItemScrollOffset =
139                     measurements.scrollOffsetToCenter(
140                         previewModel = previews.previewModels[previews.startIdx]
141                     ),
142             )
143         }
144 
145         LazyRow(
146             state = carouselState,
147             horizontalArrangement = Arrangement.spacedBy(4.dp),
148             contentPadding =
149                 PaddingValues(
150                     start = measurements.horizontalPaddingDp,
151                     end = measurements.horizontalPaddingDp,
152                 ),
153             modifier = Modifier.fillMaxSize().systemGestureExclusion(),
154         ) {
155             itemsIndexed(
156                 items = previews.previewModels,
157                 key = { _, model -> model.key.key to model.key.isFinal },
158             ) { index, model ->
159                 val visibleItem by remember {
160                     derivedStateOf {
161                         carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
162                     }
163                 }
164 
165                 // Index if this is the element in the center of the viewing area, otherwise null
166                 val previewIndex by remember {
167                     derivedStateOf {
168                         visibleItem?.let {
169                             val halfPreviewWidth = it.size / 2
170                             val previewCenter = it.offset + halfPreviewWidth
171                             val previewDistanceToViewportCenter =
172                                 abs(previewCenter - measurements.viewportCenterPx)
173                             if (previewDistanceToViewportCenter <= halfPreviewWidth) {
174                                 index
175                             } else {
176                                 null
177                             }
178                         }
179                     }
180                 }
181 
182                 val previewModel =
183                     viewModel.preview(
184                         /* key = */ model,
185                         /* previewHeight = */ measurements.viewportHeightPx,
186                         /* index = */ previewIndex,
187                         /* scope = */ rememberCoroutineScope(),
188                     )
189 
190                 if (shareouselScrollOffscreenSelections()) {
191                     LaunchedEffect(index, model.uri) {
192                         var current: Boolean? = null
193                         previewModel.isSelected.collect { selected ->
194                             when {
195                                 // First update will always be the current state, so we just want to
196                                 // record the state and do nothing else.
197                                 current == null -> current = selected
198 
199                                 // We only want to act when the state changes
200                                 current != selected -> {
201                                     current = selected
202                                     with(carouselState.layoutInfo) {
203                                         visibleItemsInfo
204                                             .firstOrNull { it.index == index }
205                                             ?.let { item ->
206                                                 when {
207                                                     // Item is partially past start of viewport
208                                                     item.offset < viewportStartOffset ->
209                                                         measurements.scrollOffsetToStartEdge()
210                                                     // Item is partially past end of viewport
211                                                     (item.offset + item.size) > viewportEndOffset ->
212                                                         measurements.scrollOffsetToEndEdge(model)
213                                                     // Item is fully within viewport
214                                                     else -> null
215                                                 }?.let { scrollOffset ->
216                                                     carouselState.animateScrollToItem(
217                                                         index = index,
218                                                         scrollOffset = scrollOffset,
219                                                     )
220                                                 }
221                                             }
222                                     }
223                                 }
224                             }
225                         }
226                     }
227                 }
228 
229                 ShareouselCard(
230                     viewModel = previewModel,
231                     aspectRatio = measurements.coerceAspectRatio(previewModel.aspectRatio),
232                 )
233             }
234         }
235     }
236 }
237 
238 @Composable
ShareouselCardnull239 private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, aspectRatio: Float) {
240     val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle()
241     val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false)
242     val borderColor = MaterialTheme.colorScheme.primary
243     val scope = rememberCoroutineScope()
244     val contentDescription =
245         when (viewModel.contentType) {
246             ContentType.Image -> stringResource(R.string.selectable_image)
247             ContentType.Video -> stringResource(R.string.selectable_video)
248             else -> stringResource(R.string.selectable_item)
249         }
250     Crossfade(
251         targetState = bitmapLoadState,
252         modifier =
253             Modifier.semantics { this.contentDescription = contentDescription }
254                 .clip(RoundedCornerShape(size = 12.dp))
255                 .toggleable(
256                     value = selected,
257                     onValueChange = { scope.launch { viewModel.setSelected(it) } },
258                 ),
259     ) { state ->
260         if (state is ValueUpdate.Value) {
261             state.getOrDefault(null).let { bitmap ->
262                 ShareouselCard(
263                     image = {
264                         bitmap?.let {
265                             Image(
266                                 bitmap = bitmap.asImageBitmap(),
267                                 contentDescription = null,
268                                 contentScale = ContentScale.Crop,
269                                 modifier = Modifier.aspectRatio(aspectRatio),
270                             )
271                         } ?: PlaceholderBox(aspectRatio)
272                     },
273                     contentType = viewModel.contentType,
274                     selected = selected,
275                     modifier =
276                         Modifier.thenIf(selected) {
277                             Modifier.border(
278                                 width = 4.dp,
279                                 color = borderColor,
280                                 shape = RoundedCornerShape(size = 12.dp),
281                             )
282                         },
283                 )
284             }
285         } else {
286             PlaceholderBox(aspectRatio)
287         }
288     }
289 }
290 
291 @Composable
PlaceholderBoxnull292 private fun PlaceholderBox(aspectRatio: Float) {
293     Box(
294         modifier =
295             Modifier.fillMaxHeight()
296                 .aspectRatio(aspectRatio)
297                 .background(color = MaterialTheme.colorScheme.surfaceContainerHigh)
298     )
299 }
300 
301 @Composable
ActionCarouselnull302 private fun ActionCarousel(viewModel: ShareouselViewModel) {
303     val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList())
304     if (actions.isNotEmpty()) {
305         Spacer(Modifier.height(16.dp))
306         val visibilityFlow =
307             if (unselectFinalItem()) {
308                 viewModel.hasSelectedItems
309             } else {
310                 MutableStateFlow(true)
311             }
312         val visibility by visibilityFlow.collectAsStateWithLifecycle(true)
313         val height = 32.dp
314         if (visibility) {
315             LazyRow(
316                 horizontalArrangement = Arrangement.spacedBy(4.dp),
317                 modifier = Modifier.height(height),
318             ) {
319                 itemsIndexed(actions) { idx, actionViewModel ->
320                     if (idx == 0) {
321                         Spacer(
322                             Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))
323                         )
324                     }
325                     ShareouselAction(
326                         label = actionViewModel.label,
327                         onClick = { actionViewModel.onClicked() },
328                     ) {
329                         actionViewModel.icon?.let {
330                             Image(
331                                 icon = it,
332                                 modifier = Modifier.size(16.dp),
333                                 colorFilter = ColorFilter.tint(LocalContentColor.current),
334                             )
335                         }
336                     }
337                     if (idx == actions.size - 1) {
338                         Spacer(
339                             Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))
340                         )
341                     }
342                 }
343             }
344         } else {
345             Spacer(modifier = Modifier.height(height))
346         }
347     }
348 }
349 
350 @Composable
ShareouselActionnull351 private fun ShareouselAction(
352     label: String,
353     onClick: () -> Unit,
354     modifier: Modifier = Modifier,
355     leadingIcon: (@Composable () -> Unit)? = null,
356 ) {
357     AssistChip(
358         onClick = onClick,
359         label = { Text(label) },
360         leadingIcon = leadingIcon,
361         border = null,
362         shape = RoundedCornerShape(1000.dp), // pill shape.
363         colors =
364             AssistChipDefaults.assistChipColors(
365                 containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
366                 labelColor = MaterialTheme.colorScheme.onSurface,
367                 leadingIconContentColor = MaterialTheme.colorScheme.onSurface,
368             ),
369         modifier = modifier,
370     )
371 }
372 
thenIfnull373 inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier =
374     if (condition) this.then(factory()) else this
375 
376 private data class PreviewCarouselMeasurements(
377     val viewportHeightPx: Int,
378     val viewportWidthPx: Int,
379     val viewportCenterPx: Int = viewportWidthPx / 2,
380     val maxAspectRatio: Float,
381     val horizontalPaddingPx: Int,
382     val horizontalPaddingDp: Dp,
383 ) {
384     constructor(
385         placeable: Placeable,
386         measureScope: MeasureScope,
387         horizontalPadding: Float = (placeable.width - (MIN_ASPECT_RATIO * placeable.height)) / 2,
388     ) : this(
389         viewportHeightPx = placeable.height,
390         viewportWidthPx = placeable.width,
391         maxAspectRatio =
392             with(measureScope) {
393                 min(
394                     (placeable.width - 32.dp.roundToPx()).toFloat() / placeable.height,
395                     MAX_ASPECT_RATIO,
396                 )
397             },
398         horizontalPaddingPx = horizontalPadding.roundToInt(),
399         horizontalPaddingDp = with(measureScope) { horizontalPadding.toDp() },
400     )
401 
402     fun coerceAspectRatio(ratio: Float): Float = ratio.coerceIn(MIN_ASPECT_RATIO, maxAspectRatio)
403 
404     fun scrollOffsetToCenter(previewModel: PreviewModel): Int =
405         horizontalPaddingPx + (aspectRatioToWidthPx(previewModel.aspectRatio) / 2) -
406             viewportCenterPx
407 
408     fun scrollOffsetToStartEdge(): Int = horizontalPaddingPx
409 
410     fun scrollOffsetToEndEdge(previewModel: PreviewModel): Int =
411         horizontalPaddingPx + aspectRatioToWidthPx(previewModel.aspectRatio) - viewportWidthPx
412 
413     private fun aspectRatioToWidthPx(ratio: Float): Int =
414         (coerceAspectRatio(ratio) * viewportHeightPx).roundToInt()
415 
416     companion object {
417         private const val MIN_ASPECT_RATIO = 0.4f
418         private const val MAX_ASPECT_RATIO = 2.5f
419 
420         val UNMEASURED =
421             PreviewCarouselMeasurements(
422                 viewportHeightPx = 0,
423                 viewportWidthPx = 0,
424                 maxAspectRatio = 0f,
425                 horizontalPaddingPx = 0,
426                 horizontalPaddingDp = 0.dp,
427             )
428     }
429 }
430