• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 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.systemui.communal.ui.compose
18 
19 import android.appwidget.AppWidgetProviderInfo
20 import android.content.Context
21 import android.content.res.Configuration
22 import android.graphics.drawable.Icon
23 import android.os.SystemClock
24 import android.util.SizeF
25 import android.view.MotionEvent
26 import android.widget.FrameLayout
27 import android.widget.RemoteViews
28 import androidx.compose.animation.AnimatedVisibility
29 import androidx.compose.animation.core.LinearEasing
30 import androidx.compose.animation.core.Spring
31 import androidx.compose.animation.core.animateFloatAsState
32 import androidx.compose.animation.core.spring
33 import androidx.compose.animation.core.tween
34 import androidx.compose.animation.fadeIn
35 import androidx.compose.animation.fadeOut
36 import androidx.compose.animation.slideInVertically
37 import androidx.compose.animation.slideOutVertically
38 import androidx.compose.foundation.BorderStroke
39 import androidx.compose.foundation.ExperimentalFoundationApi
40 import androidx.compose.foundation.Image
41 import androidx.compose.foundation.background
42 import androidx.compose.foundation.clickable
43 import androidx.compose.foundation.focusable
44 import androidx.compose.foundation.gestures.awaitFirstDown
45 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
46 import androidx.compose.foundation.gestures.snapping.SnapPosition
47 import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
48 import androidx.compose.foundation.interaction.MutableInteractionSource
49 import androidx.compose.foundation.layout.Arrangement
50 import androidx.compose.foundation.layout.Box
51 import androidx.compose.foundation.layout.BoxScope
52 import androidx.compose.foundation.layout.Column
53 import androidx.compose.foundation.layout.PaddingValues
54 import androidx.compose.foundation.layout.Row
55 import androidx.compose.foundation.layout.RowScope
56 import androidx.compose.foundation.layout.Spacer
57 import androidx.compose.foundation.layout.WindowInsets
58 import androidx.compose.foundation.layout.asPaddingValues
59 import androidx.compose.foundation.layout.calculateStartPadding
60 import androidx.compose.foundation.layout.displayCutout
61 import androidx.compose.foundation.layout.fillMaxHeight
62 import androidx.compose.foundation.layout.fillMaxSize
63 import androidx.compose.foundation.layout.fillMaxWidth
64 import androidx.compose.foundation.layout.height
65 import androidx.compose.foundation.layout.heightIn
66 import androidx.compose.foundation.layout.padding
67 import androidx.compose.foundation.layout.requiredSize
68 import androidx.compose.foundation.layout.size
69 import androidx.compose.foundation.layout.width
70 import androidx.compose.foundation.layout.widthIn
71 import androidx.compose.foundation.layout.wrapContentHeight
72 import androidx.compose.foundation.lazy.grid.GridCells
73 import androidx.compose.foundation.lazy.grid.GridItemSpan
74 import androidx.compose.foundation.lazy.grid.LazyGridScope
75 import androidx.compose.foundation.lazy.grid.LazyGridState
76 import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
77 import androidx.compose.foundation.lazy.grid.itemsIndexed
78 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
79 import androidx.compose.foundation.rememberScrollState
80 import androidx.compose.foundation.selection.selectable
81 import androidx.compose.foundation.shape.RoundedCornerShape
82 import androidx.compose.foundation.text.BasicText
83 import androidx.compose.foundation.text.TextAutoSize
84 import androidx.compose.foundation.verticalScroll
85 import androidx.compose.material.icons.Icons
86 import androidx.compose.material.icons.filled.Add
87 import androidx.compose.material.icons.filled.Check
88 import androidx.compose.material.icons.filled.Close
89 import androidx.compose.material.icons.outlined.Edit
90 import androidx.compose.material.icons.outlined.Widgets
91 import androidx.compose.material3.Button
92 import androidx.compose.material3.ButtonColors
93 import androidx.compose.material3.ButtonDefaults
94 import androidx.compose.material3.Card
95 import androidx.compose.material3.CardDefaults
96 import androidx.compose.material3.ExperimentalMaterial3Api
97 import androidx.compose.material3.FilledIconButton
98 import androidx.compose.material3.Icon
99 import androidx.compose.material3.IconButtonColors
100 import androidx.compose.material3.MaterialTheme
101 import androidx.compose.material3.ModalBottomSheet
102 import androidx.compose.material3.OutlinedButton
103 import androidx.compose.material3.Text
104 import androidx.compose.material3.rememberModalBottomSheetState
105 import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
106 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
107 import androidx.compose.runtime.Composable
108 import androidx.compose.runtime.CompositionLocalProvider
109 import androidx.compose.runtime.LaunchedEffect
110 import androidx.compose.runtime.State
111 import androidx.compose.runtime.derivedStateOf
112 import androidx.compose.runtime.getValue
113 import androidx.compose.runtime.mutableStateOf
114 import androidx.compose.runtime.remember
115 import androidx.compose.runtime.rememberCoroutineScope
116 import androidx.compose.runtime.setValue
117 import androidx.compose.runtime.snapshotFlow
118 import androidx.compose.ui.Alignment
119 import androidx.compose.ui.Modifier
120 import androidx.compose.ui.draw.clip
121 import androidx.compose.ui.draw.drawBehind
122 import androidx.compose.ui.focus.FocusRequester
123 import androidx.compose.ui.focus.focusRequester
124 import androidx.compose.ui.geometry.CornerRadius
125 import androidx.compose.ui.geometry.Offset
126 import androidx.compose.ui.geometry.Size
127 import androidx.compose.ui.graphics.Color
128 import androidx.compose.ui.graphics.ColorFilter
129 import androidx.compose.ui.graphics.ColorMatrix
130 import androidx.compose.ui.graphics.SolidColor
131 import androidx.compose.ui.graphics.drawscope.Stroke
132 import androidx.compose.ui.graphics.graphicsLayer
133 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
134 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
135 import androidx.compose.ui.input.nestedscroll.nestedScroll
136 import androidx.compose.ui.input.pointer.changedToUp
137 import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
138 import androidx.compose.ui.input.pointer.pointerInput
139 import androidx.compose.ui.layout.LayoutCoordinates
140 import androidx.compose.ui.layout.boundsInWindow
141 import androidx.compose.ui.layout.onGloballyPositioned
142 import androidx.compose.ui.layout.onSizeChanged
143 import androidx.compose.ui.layout.positionInWindow
144 import androidx.compose.ui.platform.LocalConfiguration
145 import androidx.compose.ui.platform.LocalContext
146 import androidx.compose.ui.platform.LocalDensity
147 import androidx.compose.ui.platform.LocalLayoutDirection
148 import androidx.compose.ui.platform.testTag
149 import androidx.compose.ui.res.dimensionResource
150 import androidx.compose.ui.res.stringResource
151 import androidx.compose.ui.semantics.CustomAccessibilityAction
152 import androidx.compose.ui.semantics.clearAndSetSemantics
153 import androidx.compose.ui.semantics.contentDescription
154 import androidx.compose.ui.semantics.customActions
155 import androidx.compose.ui.semantics.heading
156 import androidx.compose.ui.semantics.onClick
157 import androidx.compose.ui.semantics.paneTitle
158 import androidx.compose.ui.semantics.semantics
159 import androidx.compose.ui.semantics.testTagsAsResourceId
160 import androidx.compose.ui.text.style.TextAlign
161 import androidx.compose.ui.unit.Density
162 import androidx.compose.ui.unit.Dp
163 import androidx.compose.ui.unit.DpSize
164 import androidx.compose.ui.unit.IntOffset
165 import androidx.compose.ui.unit.IntRect
166 import androidx.compose.ui.unit.IntSize
167 import androidx.compose.ui.unit.LayoutDirection
168 import androidx.compose.ui.unit.dp
169 import androidx.compose.ui.unit.round
170 import androidx.compose.ui.unit.sp
171 import androidx.compose.ui.unit.times
172 import androidx.compose.ui.util.fastAll
173 import androidx.compose.ui.viewinterop.AndroidView
174 import androidx.compose.ui.viewinterop.NoOpUpdate
175 import androidx.compose.ui.zIndex
176 import androidx.lifecycle.compose.collectAsStateWithLifecycle
177 import androidx.window.layout.WindowMetricsCalculator
178 import com.android.compose.animation.Easings.Emphasized
179 import com.android.compose.animation.scene.ContentScope
180 import com.android.compose.modifiers.thenIf
181 import com.android.compose.ui.graphics.painter.rememberDrawablePainter
182 import com.android.compose.windowsizeclass.LocalWindowSizeClass
183 import com.android.internal.R.dimen.system_app_widget_background_radius
184 import com.android.systemui.Flags
185 import com.android.systemui.Flags.communalResponsiveGrid
186 import com.android.systemui.Flags.communalTimerFlickerFix
187 import com.android.systemui.Flags.communalWidgetResizing
188 import com.android.systemui.communal.domain.model.CommunalContentModel
189 import com.android.systemui.communal.shared.model.CommunalContentSize
190 import com.android.systemui.communal.shared.model.CommunalScenes
191 import com.android.systemui.communal.ui.compose.extensions.allowGestures
192 import com.android.systemui.communal.ui.compose.extensions.detectLongPressGesture
193 import com.android.systemui.communal.ui.compose.extensions.firstItemAtOffset
194 import com.android.systemui.communal.ui.compose.extensions.observeTaps
195 import com.android.systemui.communal.ui.view.layout.sections.CommunalAppWidgetSection
196 import com.android.systemui.communal.ui.viewmodel.BaseCommunalViewModel
197 import com.android.systemui.communal.ui.viewmodel.CommunalEditModeViewModel
198 import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
199 import com.android.systemui.communal.ui.viewmodel.ResizeInfo
200 import com.android.systemui.communal.ui.viewmodel.ResizeableItemFrameViewModel
201 import com.android.systemui.communal.util.DensityUtils.Companion.adjustedDp
202 import com.android.systemui.communal.util.ResizeUtils.resizeOngoingItems
203 import com.android.systemui.communal.widgets.SmartspaceAppWidgetHostView
204 import com.android.systemui.communal.widgets.WidgetConfigurator
205 import com.android.systemui.lifecycle.rememberViewModel
206 import com.android.systemui.media.controls.ui.composable.MediaCarousel
207 import com.android.systemui.res.R
208 import com.android.systemui.scene.shared.flag.SceneContainerFlag
209 import com.android.systemui.statusbar.phone.SystemUIDialogFactory
210 import kotlin.math.max
211 import kotlin.math.min
212 import kotlinx.coroutines.delay
213 import kotlinx.coroutines.launch
214 
215 @OptIn(ExperimentalMaterial3Api::class)
216 @Composable
217 fun CommunalHub(
218     modifier: Modifier = Modifier,
219     viewModel: BaseCommunalViewModel,
220     widgetSection: CommunalAppWidgetSection,
221     interactionHandler: RemoteViews.InteractionHandler? = null,
222     dialogFactory: SystemUIDialogFactory? = null,
223     widgetConfigurator: WidgetConfigurator? = null,
224     onOpenWidgetPicker: (() -> Unit)? = null,
225     onEditDone: (() -> Unit)? = null,
226     contentScope: ContentScope? = null,
227 ) {
228     val communalContent by
229         viewModel.communalContent.collectAsStateWithLifecycle(initialValue = emptyList())
230     var removeButtonCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
231     var toolbarSize: IntSize? by remember { mutableStateOf(null) }
232     var gridCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
233     var contentOffset: Offset by remember { mutableStateOf(Offset.Zero) }
234 
235     val gridState =
236         rememberLazyGridState(viewModel.savedFirstScrollIndex, viewModel.savedFirstScrollOffset)
237 
238     LaunchedEffect(Unit) {
239         if (!viewModel.isEditMode) {
240             viewModel.clearPersistedScrollPosition()
241         }
242     }
243 
244     val contentListState = rememberContentListState(widgetConfigurator, communalContent, viewModel)
245     val reorderingWidgets by viewModel.reorderingWidgets.collectAsStateWithLifecycle()
246     val selectedKey = viewModel.selectedKey.collectAsStateWithLifecycle()
247     val removeButtonEnabled by remember {
248         derivedStateOf { selectedKey.value != null || reorderingWidgets }
249     }
250     val isEmptyState by viewModel.isEmptyState.collectAsStateWithLifecycle(initialValue = false)
251     val isCommunalContentVisible by
252         viewModel.isCommunalContentVisible.collectAsStateWithLifecycle(
253             initialValue = !viewModel.isEditMode
254         )
255 
256     val minContentPadding = gridContentPadding(viewModel.isEditMode, toolbarSize)
257     ObserveScrollEffect(gridState, viewModel)
258 
259     val context = LocalContext.current
260     val windowMetrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context)
261     val screenWidth = windowMetrics.bounds.width()
262     val layoutDirection = LocalLayoutDirection.current
263 
264     if (viewModel.isEditMode) {
265         ObserveNewWidgetAddedEffect(communalContent, gridState, viewModel)
266     } else {
267         ScrollOnUpdatedLiveContentEffect(communalContent, gridState)
268     }
269 
270     val nestedScrollConnection = remember {
271         object : NestedScrollConnection {
272             override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
273                 // Begin tracking nested scrolling
274                 viewModel.onNestedScrolling()
275                 return super.onPreScroll(available, source)
276             }
277         }
278     }
279 
280     val paneTitle = stringResource(R.string.accessibility_content_description_for_communal_hub)
281 
282     Box(
283         modifier =
284             modifier
285                 .semantics {
286                     testTagsAsResourceId = true
287                     this.paneTitle = paneTitle
288                 }
289                 .testTag(COMMUNAL_HUB_TEST_TAG)
290                 .fillMaxSize()
291                 // Observe taps for selecting items
292                 .thenIf(viewModel.isEditMode) {
293                     Modifier.pointerInput(
294                         layoutDirection,
295                         gridState,
296                         contentOffset,
297                         contentListState,
298                     ) {
299                         observeTaps { offset ->
300                             // if RTL, flip offset direction from Left side to Right
301                             val adjustedOffset =
302                                 Offset(
303                                     if (layoutDirection == LayoutDirection.Rtl)
304                                         screenWidth - offset.x
305                                     else offset.x,
306                                     offset.y,
307                                 ) - contentOffset
308                             val index = firstIndexAtOffset(gridState, adjustedOffset)
309                             val tappedKey =
310                                 index?.let { keyAtIndexIfEditable(contentListState.list, index) }
311 
312                             viewModel.setSelectedKey(
313                                 if (
314                                     Flags.hubEditModeTouchAdjustments() &&
315                                         selectedKey.value == tappedKey
316                                 ) {
317                                     null
318                                 } else {
319                                     tappedKey
320                                 }
321                             )
322                         }
323                     }
324                 }
325                 // Nested scroll for full screen swipe to get to shade and bouncer
326                 .thenIf(!viewModel.isEditMode && Flags.hubmodeFullscreenVerticalSwipeFix()) {
327                     Modifier.nestedScroll(nestedScrollConnection).pointerInput(viewModel) {
328                         awaitPointerEventScope {
329                             while (true) {
330                                 val firstDownEvent = awaitFirstDown(requireUnconsumed = false)
331                                 // Reset touch on first event.
332                                 viewModel.onResetTouchState()
333 
334                                 // Process down event in case it's consumed immediately
335                                 if (firstDownEvent.isConsumed) {
336                                     viewModel.onHubTouchConsumed()
337                                 }
338 
339                                 do {
340                                     val event = awaitPointerEvent()
341                                     for (change in event.changes) {
342                                         if (change.isConsumed) {
343                                             // Signal touch consumption on any consumed event.
344                                             viewModel.onHubTouchConsumed()
345                                         }
346                                     }
347                                 } while (
348                                     !event.changes.fastAll {
349                                         it.changedToUp() || it.changedToUpIgnoreConsumed()
350                                     }
351                                 )
352 
353                                 // Reset state once touch ends.
354                                 viewModel.onResetTouchState()
355                             }
356                         }
357                     }
358                 }
359                 .thenIf(!viewModel.isEditMode && !isEmptyState) {
360                     Modifier.pointerInput(
361                         gridState,
362                         contentOffset,
363                         communalContent,
364                         gridCoordinates,
365                     ) {
366                         detectLongPressGesture { offset ->
367                             // Deduct both grid offset relative to its container and content
368                             // offset.
369                             val adjustedOffset =
370                                 gridCoordinates?.let {
371                                     Offset(
372                                         if (layoutDirection == LayoutDirection.Rtl)
373                                             screenWidth - offset.x
374                                         else offset.x,
375                                         offset.y,
376                                     ) - it.positionInWindow() - contentOffset
377                                 }
378                             val index = adjustedOffset?.let { firstIndexAtOffset(gridState, it) }
379                             val key = index?.let { keyAtIndexIfEditable(communalContent, index) }
380                             // Handle long-click on widgets and set the selected index
381                             // correctly. We only handle widgets here because long click on
382                             // empty spaces is handled by CommunalPopupSection.
383                             if (key != null) {
384                                 viewModel.onLongClick()
385                                 viewModel.setSelectedKey(key)
386                             }
387                         }
388                     }
389                 }
390     ) {
391         AccessibilityContainer(viewModel) {
392             if (!viewModel.isEditMode && isEmptyState) {
393                 EmptyStateCta(contentPadding = minContentPadding, viewModel = viewModel)
394             } else {
395                 val slideOffsetInPx =
396                     with(LocalDensity.current) { Dimensions.SlideOffsetY.toPx().toInt() }
397                 AnimatedVisibility(
398                     visible = isCommunalContentVisible,
399                     enter =
400                         fadeIn(
401                             animationSpec =
402                                 tween(durationMillis = 83, delayMillis = 83, easing = LinearEasing)
403                         ) +
404                             slideInVertically(
405                                 animationSpec = tween(durationMillis = 1000, easing = Emphasized),
406                                 initialOffsetY = { -slideOffsetInPx },
407                             ),
408                     exit =
409                         fadeOut(
410                             animationSpec = tween(durationMillis = 167, easing = LinearEasing)
411                         ) +
412                             slideOutVertically(
413                                 animationSpec = tween(durationMillis = 1000, easing = Emphasized),
414                                 targetOffsetY = { -slideOffsetInPx },
415                             ),
416                     modifier = Modifier.fillMaxSize(),
417                 ) {
418                     Box {
419                         CommunalHubLazyGrid(
420                             communalContent = communalContent,
421                             viewModel = viewModel,
422                             minContentPadding = minContentPadding,
423                             contentOffset = contentOffset,
424                             screenWidth = screenWidth,
425                             setGridCoordinates = { gridCoordinates = it },
426                             setContentOffset = { contentOffset = it },
427                             updateDragPositionForRemove = { boundingBox ->
428                                 val gridOffset = gridCoordinates?.positionInWindow()
429                                 val removeButtonCenter =
430                                     removeButtonCoordinates?.boundsInWindow()?.center
431                                 removeButtonEnabled &&
432                                     gridOffset != null &&
433                                     removeButtonCenter != null &&
434                                     boundingBox
435                                         // The bounding box is relative to the grid, so we need to
436                                         // normalize it by adding the grid offset and the content
437                                         // offset.
438                                         .translate((gridOffset + contentOffset).round())
439                                         .contains(removeButtonCenter.round())
440                             },
441                             gridState = gridState,
442                             contentListState = contentListState,
443                             selectedKey = selectedKey,
444                             widgetConfigurator = widgetConfigurator,
445                             interactionHandler = interactionHandler,
446                             widgetSection = widgetSection,
447                             contentScope = contentScope,
448                         )
449                     }
450                 }
451             }
452         }
453 
454         if (onOpenWidgetPicker != null && onEditDone != null) {
455             AnimatedVisibility(
456                 visible = viewModel.isEditMode && isCommunalContentVisible,
457                 enter =
458                     fadeIn(animationSpec = tween(durationMillis = 250, easing = LinearEasing)) +
459                         slideInVertically(
460                             animationSpec = tween(durationMillis = 1000, easing = Emphasized)
461                         ),
462                 exit =
463                     fadeOut(animationSpec = tween(durationMillis = 167, easing = LinearEasing)) +
464                         slideOutVertically(
465                             animationSpec = tween(durationMillis = 1000, easing = Emphasized)
466                         ),
467             ) {
468                 Toolbar(
469                     setToolbarSize = { toolbarSize = it },
470                     setRemoveButtonCoordinates = { removeButtonCoordinates = it },
471                     onEditDone = onEditDone,
472                     onOpenWidgetPicker = onOpenWidgetPicker,
473                     onRemoveClicked = {
474                         val index =
475                             selectedKey.value?.let { key ->
476                                 contentListState.list.indexOfFirst { it.key == key }
477                             }
478                         index?.let {
479                             contentListState.onRemove(it)
480                             contentListState.onSaveList()
481                             viewModel.setSelectedKey(null)
482                         }
483                     },
484                     removeEnabled = removeButtonEnabled,
485                 )
486             }
487         }
488 
489         if (viewModel is CommunalViewModel && dialogFactory != null) {
490             val isEnableWidgetDialogShowing by
491                 viewModel.isEnableWidgetDialogShowing.collectAsStateWithLifecycle(false)
492             val isEnableWorkProfileDialogShowing by
493                 viewModel.isEnableWorkProfileDialogShowing.collectAsStateWithLifecycle(false)
494 
495             EnableWidgetDialog(
496                 isEnableWidgetDialogVisible = isEnableWidgetDialogShowing,
497                 dialogFactory = dialogFactory,
498                 title = stringResource(id = R.string.dialog_title_to_allow_any_widget),
499                 positiveButtonText = stringResource(id = R.string.button_text_to_open_settings),
500                 onConfirm = viewModel::onEnableWidgetDialogConfirm,
501                 onCancel = viewModel::onEnableWidgetDialogCancel,
502             )
503 
504             EnableWidgetDialog(
505                 isEnableWidgetDialogVisible = isEnableWorkProfileDialogShowing,
506                 dialogFactory = dialogFactory,
507                 title = stringResource(id = R.string.work_mode_off_title),
508                 positiveButtonText = stringResource(id = R.string.work_mode_turn_on),
509                 onConfirm = viewModel::onEnableWorkProfileDialogConfirm,
510                 onCancel = viewModel::onEnableWorkProfileDialogCancel,
511             )
512         }
513 
514         if (viewModel is CommunalEditModeViewModel) {
515             val showBottomSheet by viewModel.showDisclaimer.collectAsStateWithLifecycle(false)
516 
517             if (showBottomSheet) {
518                 val scope = rememberCoroutineScope()
519                 val sheetState = rememberModalBottomSheetState()
520                 val colors = MaterialTheme.colorScheme
521 
522                 ModalBottomSheet(
523                     onDismissRequest = viewModel::onDisclaimerDismissed,
524                     sheetState = sheetState,
525                     dragHandle = null,
526                     containerColor = colors.surfaceContainer,
527                 ) {
528                     DisclaimerBottomSheetContent {
529                         scope
530                             .launch { sheetState.hide() }
531                             .invokeOnCompletion {
532                                 if (!sheetState.isVisible) {
533                                     viewModel.onDisclaimerDismissed()
534                                 }
535                             }
536                     }
537                 }
538             }
539         }
540     }
541 }
542 
543 val hubDimensions: Dimensions
544     @Composable get() = Dimensions(LocalContext.current, LocalConfiguration.current)
545 
546 @Composable
DisclaimerBottomSheetContentnull547 private fun DisclaimerBottomSheetContent(onButtonClicked: () -> Unit) {
548     val colors = MaterialTheme.colorScheme
549 
550     Column(
551         modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 24.dp),
552         verticalArrangement = Arrangement.Center,
553         horizontalAlignment = Alignment.CenterHorizontally,
554     ) {
555         Icon(
556             imageVector = Icons.Outlined.Widgets,
557             contentDescription = null,
558             tint = colors.primary,
559             modifier = Modifier.size(32.dp),
560         )
561         Spacer(modifier = Modifier.height(16.dp))
562         Text(
563             text = stringResource(R.string.communal_widgets_disclaimer_title),
564             style = MaterialTheme.typography.headlineMedium,
565             color = colors.onSurface,
566         )
567         Spacer(modifier = Modifier.height(16.dp))
568         Text(
569             text = stringResource(R.string.communal_widgets_disclaimer_text),
570             color = colors.onSurfaceVariant,
571         )
572         Button(
573             modifier =
574                 Modifier.padding(horizontal = 26.dp, vertical = 16.dp)
575                     .widthIn(min = 200.dp)
576                     .heightIn(min = 56.dp),
577             onClick = { onButtonClicked() },
578         ) {
579             Text(
580                 stringResource(R.string.communal_widgets_disclaimer_button),
581                 style = MaterialTheme.typography.labelLarge,
582             )
583         }
584     }
585 }
586 
587 @Composable
ObserveScrollEffectnull588 private fun ObserveScrollEffect(
589     gridState: LazyGridState,
590     communalViewModel: BaseCommunalViewModel,
591 ) {
592 
593     LaunchedEffect(gridState) {
594         snapshotFlow {
595                 Pair(gridState.firstVisibleItemIndex, gridState.firstVisibleItemScrollOffset)
596             }
597             .collect { communalViewModel.onScrollPositionUpdated(it.first, it.second) }
598     }
599 }
600 
601 /**
602  * Observes communal content and scrolls to any added or updated live content, e.g. a new media
603  * session is started, or a paused timer is resumed.
604  */
605 @Composable
ScrollOnUpdatedLiveContentEffectnull606 private fun ScrollOnUpdatedLiveContentEffect(
607     communalContent: List<CommunalContentModel>,
608     gridState: LazyGridState,
609 ) {
610     val liveContentKeys = remember { mutableListOf<String>() }
611     var communalContentPending by remember { mutableStateOf(true) }
612 
613     LaunchedEffect(communalContent) {
614         // Do nothing until any communal content comes in
615         if (communalContentPending && communalContent.isEmpty()) {
616             return@LaunchedEffect
617         }
618 
619         val prevLiveContentKeys = liveContentKeys.toList()
620         val newLiveContentKeys = communalContent.filter { it.isLiveContent() }.map { it.key }
621         liveContentKeys.clear()
622         liveContentKeys.addAll(newLiveContentKeys)
623 
624         // Do nothing on first communal content since we don't have a delta
625         if (communalContentPending) {
626             communalContentPending = false
627             return@LaunchedEffect
628         }
629 
630         // Do nothing if there is no new live content
631         val indexOfFirstUpdatedContent =
632             newLiveContentKeys.indexOfFirst { !prevLiveContentKeys.contains(it) }
633         if (indexOfFirstUpdatedContent in 0 until gridState.firstVisibleItemIndex) {
634             gridState.scrollToItem(indexOfFirstUpdatedContent)
635         }
636     }
637 }
638 
639 /**
640  * Observes communal content and determines whether a new widget has been added, upon which case:
641  * - Announce for accessibility
642  * - Scroll if the new widget is not visible
643  */
644 @Composable
ObserveNewWidgetAddedEffectnull645 private fun ObserveNewWidgetAddedEffect(
646     communalContent: List<CommunalContentModel>,
647     gridState: LazyGridState,
648     viewModel: BaseCommunalViewModel,
649 ) {
650     val coroutineScope = rememberCoroutineScope()
651     val widgetKeys = remember { mutableListOf<String>() }
652     var communalContentPending by remember { mutableStateOf(true) }
653 
654     LaunchedEffect(communalContent) {
655         // Do nothing until any communal content comes in
656         if (communalContentPending && communalContent.isEmpty()) {
657             return@LaunchedEffect
658         }
659 
660         val oldWidgetKeys = widgetKeys.toList()
661         val widgets = communalContent.filterIsInstance<CommunalContentModel.WidgetContent.Widget>()
662         widgetKeys.clear()
663         widgetKeys.addAll(widgets.map { it.key })
664 
665         // Do nothing on first communal content since we don't have a delta
666         if (communalContentPending) {
667             communalContentPending = false
668             return@LaunchedEffect
669         }
670 
671         // Do nothing if there is no new widget
672         val indexOfFirstNewWidget = widgetKeys.indexOfFirst { !oldWidgetKeys.contains(it) }
673         if (indexOfFirstNewWidget < 0) {
674             return@LaunchedEffect
675         }
676 
677         viewModel.onNewWidgetAdded(widgets[indexOfFirstNewWidget].providerInfo)
678 
679         // Scroll if the new widget is not visible
680         val lastVisibleItemIndex = gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
681         if (lastVisibleItemIndex != null && indexOfFirstNewWidget > lastVisibleItemIndex) {
682             // Launching with a scope to prevent the job from being canceled in the case of a
683             // recomposition during scrolling
684             coroutineScope.launch { gridState.animateScrollToItem(indexOfFirstNewWidget) }
685         }
686     }
687 }
688 
689 @Composable
ResizableItemFrameWrappernull690 private fun ResizableItemFrameWrapper(
691     key: String,
692     currentSpan: GridItemSpan,
693     gridState: LazyGridState,
694     gridContentPadding: PaddingValues,
695     verticalArrangement: Arrangement.Vertical,
696     enabled: Boolean,
697     minHeightPx: Int,
698     maxHeightPx: Int,
699     modifier: Modifier = Modifier,
700     alpha: () -> Float = { 1f },
701     viewModel: ResizeableItemFrameViewModel,
<lambda>null702     onResize: (info: ResizeInfo) -> Unit = {},
703     content: @Composable (modifier: Modifier) -> Unit,
704 ) {
705     if (!communalWidgetResizing()) {
706         content(modifier)
707     } else {
708         ResizableItemFrame(
709             key = key,
710             currentSpan = currentSpan,
711             gridState = gridState,
712             gridContentPadding = gridContentPadding,
713             verticalArrangement = verticalArrangement,
714             enabled = enabled,
715             alpha = alpha,
716             modifier = modifier,
717             viewModel = viewModel,
718             onResize = onResize,
719             minHeightPx = minHeightPx,
720             maxHeightPx = maxHeightPx,
721             resizeMultiple =
722                 if (communalResponsiveGrid()) {
723                     1
724                 } else {
725                     CommunalContentSize.FixedSize.HALF.span
726                 },
<lambda>null727         ) {
728             content(Modifier)
729         }
730     }
731 }
732 
733 @Composable
calculateWidgetSizenull734 fun calculateWidgetSize(
735     cellHeight: Dp?,
736     availableHeight: Dp?,
737     item: CommunalContentModel,
738     isResizable: Boolean,
739 ): WidgetSizeInfo {
740     val density = LocalDensity.current
741 
742     val minHeight = cellHeight ?: CommunalContentSize.FixedSize.HALF.dp()
743     val maxHeight = availableHeight ?: CommunalContentSize.FixedSize.FULL.dp()
744 
745     return if (isResizable && item is CommunalContentModel.WidgetContent.Widget) {
746         with(density) {
747             val minHeightPx =
748                 (min(item.providerInfo.minResizeHeight, item.providerInfo.minHeight)
749                     .coerceAtLeast(minHeight.roundToPx()))
750 
751             val maxHeightPx =
752                 (if (item.providerInfo.maxResizeHeight > 0) {
753                         max(item.providerInfo.maxResizeHeight, item.providerInfo.minHeight)
754                     } else {
755                         Int.MAX_VALUE
756                     })
757                     .coerceIn(minHeightPx, maxHeight.roundToPx())
758 
759             WidgetSizeInfo(minHeightPx, maxHeightPx)
760         }
761     } else {
762         WidgetSizeInfo(0, 0)
763     }
764 }
765 
766 @Composable
horizontalPaddingWithInsetsnull767 private fun horizontalPaddingWithInsets(padding: Dp): Dp {
768     val orientation = LocalConfiguration.current.orientation
769     val displayCutoutPaddings = WindowInsets.displayCutout.asPaddingValues()
770     val horizontalDisplayCutoutPadding =
771         remember(orientation, displayCutoutPaddings) {
772             if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
773                 maxOf(
774                     // Top in portrait becomes startPadding (or endPadding) in landscape
775                     displayCutoutPaddings.calculateTopPadding(),
776                     // Bottom in portrait becomes endPadding (or startPadding) in landscape
777                     displayCutoutPaddings.calculateBottomPadding(),
778                 )
779             } else {
780                 0.dp
781             }
782         }
783     return padding + horizontalDisplayCutoutPadding
784 }
785 
786 @Composable
HorizontalGridWrappernull787 private fun HorizontalGridWrapper(
788     minContentPadding: PaddingValues,
789     gridState: LazyGridState,
790     dragDropState: GridDragDropState?,
791     setContentOffset: (offset: Offset) -> Unit,
792     minHorizontalArrangement: Dp,
793     minVerticalArrangement: Dp,
794     modifier: Modifier = Modifier,
795     content: LazyGridScope.(sizeInfo: SizeInfo?) -> Unit,
796 ) {
797     val isDragging = dragDropState?.draggingItemKey != null
798     if (communalResponsiveGrid()) {
799         val flingBehavior =
800             rememberSnapFlingBehavior(lazyGridState = gridState, snapPosition = SnapPosition.Start)
801         ResponsiveLazyHorizontalGrid(
802             cellAspectRatio = 1.5f,
803             modifier = modifier,
804             state = gridState,
805             flingBehavior = flingBehavior,
806             minContentPadding = minContentPadding,
807             minHorizontalArrangement = minHorizontalArrangement,
808             minVerticalArrangement = minVerticalArrangement,
809             setContentOffset = setContentOffset,
810             // Temporarily disable user gesture scrolling while dragging a widget to prevent
811             // conflicts between the drag and scroll gestures. Programmatic scrolling remains
812             // enabled to allow dragging a widget beyond the visible boundaries.
813             userScrollEnabled = !isDragging,
814             content = content,
815         )
816     } else {
817         val layoutDirection = LocalLayoutDirection.current
818         val density = LocalDensity.current
819 
820         val minStartPadding = minContentPadding.calculateStartPadding(layoutDirection)
821         val minTopPadding = minContentPadding.calculateTopPadding()
822 
823         with(density) { setContentOffset(Offset(minStartPadding.toPx(), minTopPadding.toPx())) }
824 
825         LazyHorizontalGrid(
826             modifier = modifier,
827             state = gridState,
828             rows = GridCells.Fixed(CommunalContentSize.FixedSize.FULL.span),
829             contentPadding = minContentPadding,
830             horizontalArrangement = Arrangement.spacedBy(Dimensions.ItemSpacing),
831             verticalArrangement = Arrangement.spacedBy(Dimensions.ItemSpacing),
832             // Temporarily disable user gesture scrolling while dragging a widget to prevent
833             // conflicts between the drag and scroll gestures. Programmatic scrolling remains
834             // enabled to allow dragging a widget beyond the visible boundaries.
835             userScrollEnabled = !isDragging,
836         ) {
837             content(null)
838         }
839     }
840 }
841 
842 @OptIn(ExperimentalFoundationApi::class)
843 @Composable
CommunalHubLazyGridnull844 private fun BoxScope.CommunalHubLazyGrid(
845     communalContent: List<CommunalContentModel>,
846     viewModel: BaseCommunalViewModel,
847     minContentPadding: PaddingValues,
848     selectedKey: State<String?>,
849     screenWidth: Int,
850     contentOffset: Offset,
851     gridState: LazyGridState,
852     contentListState: ContentListState,
853     setGridCoordinates: (coordinates: LayoutCoordinates) -> Unit,
854     setContentOffset: (offset: Offset) -> Unit,
855     updateDragPositionForRemove: (boundingBox: IntRect) -> Boolean,
856     widgetConfigurator: WidgetConfigurator?,
857     interactionHandler: RemoteViews.InteractionHandler?,
858     widgetSection: CommunalAppWidgetSection,
859     contentScope: ContentScope?,
860 ) {
861     var gridModifier =
862         Modifier.align(Alignment.TopStart).onGloballyPositioned { setGridCoordinates(it) }
863     var list = communalContent
864     var dragDropState: GridDragDropState? = null
865     var arrangementSpacing = Dimensions.ItemSpacing
866     if (viewModel.isEditMode && viewModel is CommunalEditModeViewModel) {
867         list = contentListState.list
868         // for drag & drop operations within the communal hub grid
869         dragDropState =
870             rememberGridDragDropState(
871                 gridState = gridState,
872                 contentListState = contentListState,
873                 updateDragPositionForRemove = updateDragPositionForRemove,
874             )
875         gridModifier =
876             gridModifier
877                 .fillMaxSize()
878                 .dragContainer(
879                     dragDropState,
880                     LocalLayoutDirection.current,
881                     screenWidth,
882                     contentOffset,
883                     viewModel,
884                 )
885         // for widgets dropped from other activities
886         val dragAndDropTargetState =
887             rememberDragAndDropTargetState(
888                 gridState = gridState,
889                 contentListState = contentListState,
890                 contentOffset = contentOffset,
891             )
892 
893         // A full size box in background that listens to widget drops from the picker.
894         // Since the grid has its own listener for in-grid drag events, we use a separate element
895         // for android drag events.
896         Box(Modifier.fillMaxSize().dragAndDropTarget(dragAndDropTargetState)) {}
897     } else if (communalResponsiveGrid()) {
898         gridModifier = gridModifier.fillMaxSize()
899         if (isCompactWindow()) {
900             arrangementSpacing = Dimensions.ItemSpacingCompact
901         }
902     } else {
903         gridModifier = gridModifier.height(hubDimensions.GridHeight)
904     }
905 
906     HorizontalGridWrapper(
907         modifier = gridModifier,
908         gridState = gridState,
909         dragDropState = dragDropState,
910         minContentPadding = minContentPadding,
911         minHorizontalArrangement = arrangementSpacing,
912         minVerticalArrangement = arrangementSpacing,
913         setContentOffset = setContentOffset,
914     ) { sizeInfo ->
915         /** Override spans based on the responsive grid size */
916         val finalizedList =
917             if (sizeInfo != null) {
918                 resizeOngoingItems(list, sizeInfo.gridSize.height)
919             } else {
920                 list
921             }
922 
923         itemsIndexed(
924             items = finalizedList,
925             key = { _, item -> item.key },
926             contentType = { _, item -> item.key },
927             span = { _, item -> GridItemSpan(item.getSpanOrMax(sizeInfo?.gridSize?.height)) },
928         ) { index, item ->
929             val currentItemSpan = item.getSpanOrMax(sizeInfo?.gridSize?.height)
930             val dpSize =
931                 if (sizeInfo != null) {
932                     DpSize(sizeInfo.cellSize.width, sizeInfo.calculateHeight(currentItemSpan))
933                 } else {
934                     DpSize(Dimensions.CardWidth, (item.size as CommunalContentSize.FixedSize).dp())
935                 }
936             val size = SizeF(dpSize.width.value, dpSize.height.value)
937             val selected = item.key == selectedKey.value
938             val isResizable =
939                 if (item is CommunalContentModel.WidgetContent.Widget) {
940                     item.providerInfo.resizeMode and AppWidgetProviderInfo.RESIZE_VERTICAL != 0
941                 } else {
942                     false
943                 }
944 
945             val resizeableItemFrameViewModel =
946                 rememberViewModel(
947                     key = currentItemSpan,
948                     traceName = "ResizeableItemFrame.viewModel.$index",
949                 ) {
950                     ResizeableItemFrameViewModel()
951                 }
952             if (viewModel.isEditMode && dragDropState != null) {
953                 val isItemDragging = dragDropState.draggingItemKey == item.key
954                 val outlineAlpha by
955                     animateFloatAsState(
956                         targetValue = if (selected) 1f else 0f,
957                         animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
958                         label = "Widget resizing outline alpha",
959                     )
960 
961                 val widgetSizeInfo =
962                     calculateWidgetSize(
963                         cellHeight = sizeInfo?.cellSize?.height,
964                         availableHeight = sizeInfo?.availableHeight,
965                         item = item,
966                         isResizable = isResizable,
967                     )
968                 ResizableItemFrameWrapper(
969                     key = item.key,
970                     currentSpan = GridItemSpan(currentItemSpan),
971                     gridState = gridState,
972                     gridContentPadding = sizeInfo?.contentPadding ?: minContentPadding,
973                     verticalArrangement =
974                         Arrangement.spacedBy(
975                             sizeInfo?.verticalArrangement ?: Dimensions.ItemSpacing
976                         ),
977                     enabled = selected && !isItemDragging,
978                     alpha = { outlineAlpha },
979                     modifier =
980                         Modifier.requiredSize(dpSize)
981                             .thenIf(!isItemDragging) {
982                                 Modifier.animateItem(
983                                     placementSpec = spring(stiffness = Spring.StiffnessMediumLow),
984                                     // See b/376495198 - not supported with AndroidView
985                                     fadeOutSpec = null,
986                                 )
987                             }
988                             .thenIf(isItemDragging) { Modifier.zIndex(1f) },
989                     viewModel = resizeableItemFrameViewModel,
990                     onResize = { resizeInfo -> contentListState.resize(index, resizeInfo) },
991                     minHeightPx = widgetSizeInfo.minHeightPx,
992                     maxHeightPx = widgetSizeInfo.maxHeightPx,
993                 ) { modifier ->
994                     DraggableItem(
995                         modifier = modifier,
996                         dragDropState = dragDropState,
997                         selected = selected,
998                         enabled = item.isWidgetContent(),
999                         key = item.key,
1000                     ) { isDragging ->
1001                         CommunalContent(
1002                             modifier = Modifier.requiredSize(dpSize),
1003                             model = item,
1004                             viewModel = viewModel,
1005                             size = size,
1006                             selected = selected && !isDragging,
1007                             widgetConfigurator = widgetConfigurator,
1008                             index = index,
1009                             contentListState = contentListState,
1010                             interactionHandler = interactionHandler,
1011                             widgetSection = widgetSection,
1012                             resizeableItemFrameViewModel = resizeableItemFrameViewModel,
1013                         )
1014                     }
1015                 }
1016             } else {
1017                 val itemAlpha =
1018                     if (communalResponsiveGrid()) {
1019                         val percentVisible by
1020                             remember(gridState, index) {
1021                                 derivedStateOf { calculatePercentVisible(gridState, index) }
1022                             }
1023                         animateFloatAsState(percentVisible)
1024                     } else {
1025                         null
1026                     }
1027 
1028                 CommunalContent(
1029                     model = item,
1030                     viewModel = viewModel,
1031                     size = size,
1032                     selected = false,
1033                     modifier =
1034                         Modifier.requiredSize(dpSize)
1035                             .animateItem(
1036                                 // See b/376495198 - not supported with AndroidView
1037                                 fadeOutSpec = null
1038                             )
1039                             .thenIf(communalResponsiveGrid()) {
1040                                 Modifier.graphicsLayer { alpha = itemAlpha?.value ?: 1f }
1041                             },
1042                     index = index,
1043                     contentListState = contentListState,
1044                     interactionHandler = interactionHandler,
1045                     widgetSection = widgetSection,
1046                     resizeableItemFrameViewModel = resizeableItemFrameViewModel,
1047                     contentScope = contentScope,
1048                 )
1049             }
1050         }
1051     }
1052 }
1053 
1054 /**
1055  * The empty state displays a fullscreen call-to-action (CTA) tile when no widgets are available.
1056  */
1057 @Composable
EmptyStateCtanull1058 private fun EmptyStateCta(contentPadding: PaddingValues, viewModel: BaseCommunalViewModel) {
1059     val colors = MaterialTheme.colorScheme
1060     Card(
1061         modifier = Modifier.height(hubDimensions.GridHeight).padding(contentPadding),
1062         colors =
1063             CardDefaults.cardColors(
1064                 containerColor = colors.primary,
1065                 contentColor = colors.onPrimary,
1066             ),
1067         shape = RoundedCornerShape(size = 80.adjustedDp),
1068     ) {
1069         Column(
1070             modifier = Modifier.fillMaxSize().padding(horizontal = 110.adjustedDp),
1071             verticalArrangement =
1072                 Arrangement.spacedBy(Dimensions.Spacing, Alignment.CenterVertically),
1073             horizontalAlignment = Alignment.CenterHorizontally,
1074         ) {
1075             val titleForEmptyStateCTA = stringResource(R.string.title_for_empty_state_cta)
1076             BasicText(
1077                 text = titleForEmptyStateCTA,
1078                 style =
1079                     MaterialTheme.typography.displaySmall.merge(
1080                         color = colors.onPrimary,
1081                         textAlign = TextAlign.Center,
1082                     ),
1083                 autoSize = TextAutoSize.StepBased(maxFontSize = 36.sp, stepSize = 0.1.sp),
1084                 modifier =
1085                     Modifier.focusable().semantics(mergeDescendants = true) {
1086                         contentDescription = titleForEmptyStateCTA
1087                         heading()
1088                     },
1089             )
1090 
1091             Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
1092                 Button(
1093                     modifier = Modifier.height(56.dp),
1094                     colors =
1095                         ButtonDefaults.buttonColors(
1096                             containerColor = colors.primaryContainer,
1097                             contentColor = colors.onPrimaryContainer,
1098                         ),
1099                     onClick = { viewModel.onOpenWidgetEditor(shouldOpenWidgetPickerOnStart = true) },
1100                 ) {
1101                     Icon(
1102                         imageVector = Icons.Default.Add,
1103                         contentDescription = null,
1104                         modifier = Modifier.size(24.dp),
1105                     )
1106                     Spacer(Modifier.width(ButtonDefaults.IconSpacing))
1107                     Text(
1108                         text = stringResource(R.string.label_for_button_in_empty_state_cta),
1109                         style = MaterialTheme.typography.titleSmall,
1110                     )
1111                 }
1112             }
1113         }
1114     }
1115 }
1116 
1117 /**
1118  * Toolbar that contains action buttons to
1119  * 1) open the widget picker
1120  * 2) remove a widget from the grid and
1121  * 3) exit the edit mode.
1122  */
1123 @Composable
Toolbarnull1124 private fun Toolbar(
1125     removeEnabled: Boolean,
1126     onRemoveClicked: () -> Unit,
1127     setToolbarSize: (toolbarSize: IntSize) -> Unit,
1128     setRemoveButtonCoordinates: (coordinates: LayoutCoordinates?) -> Unit,
1129     onOpenWidgetPicker: () -> Unit,
1130     onEditDone: () -> Unit,
1131 ) {
1132     if (!removeEnabled) {
1133         // Clear any existing coordinates when remove is not enabled.
1134         setRemoveButtonCoordinates(null)
1135     }
1136     val removeButtonAlpha: Float by
1137         animateFloatAsState(
1138             targetValue = if (removeEnabled) 1f else 0.5f,
1139             label = "RemoveButtonAlphaAnimation",
1140         )
1141 
1142     Box(
1143         modifier =
1144             Modifier.fillMaxWidth()
1145                 .padding(
1146                     top = Dimensions.ToolbarPaddingTop,
1147                     start = Dimensions.ToolbarPaddingHorizontal,
1148                     end = Dimensions.ToolbarPaddingHorizontal,
1149                 )
1150                 .onSizeChanged { setToolbarSize(it) }
1151     ) {
1152         val addWidgetText = stringResource(R.string.hub_mode_add_widget_button_text)
1153 
1154         if (!(Flags.hubEditModeTouchAdjustments() && removeEnabled)) {
1155             ToolbarButton(
1156                 isPrimary = !removeEnabled,
1157                 modifier = Modifier.align(Alignment.CenterStart),
1158                 onClick = onOpenWidgetPicker,
1159             ) {
1160                 Icon(Icons.Default.Add, null)
1161                 Text(text = addWidgetText)
1162             }
1163         }
1164 
1165         AnimatedVisibility(
1166             modifier =
1167                 Modifier.align(
1168                     if (Flags.hubEditModeTouchAdjustments()) {
1169                         Alignment.CenterStart
1170                     } else {
1171                         Alignment.Center
1172                     }
1173                 ),
1174             visible = removeEnabled,
1175             enter = fadeIn(),
1176             exit = fadeOut(),
1177         ) {
1178             Button(
1179                 onClick = onRemoveClicked,
1180                 colors = filledButtonColors(),
1181                 contentPadding = Dimensions.ButtonPadding,
1182                 modifier =
1183                     Modifier.graphicsLayer { alpha = removeButtonAlpha }
1184                         .onGloballyPositioned {
1185                             // It's possible for this callback to fire after remove has been
1186                             // disabled. Check enabled state before setting.
1187                             if (removeEnabled) {
1188                                 setRemoveButtonCoordinates(it)
1189                             }
1190                         },
1191             ) {
1192                 Row(
1193                     horizontalArrangement =
1194                         Arrangement.spacedBy(
1195                             ButtonDefaults.IconSpacing,
1196                             if (Flags.hubEditModeTouchAdjustments()) {
1197                                 Alignment.Start
1198                             } else {
1199                                 Alignment.CenterHorizontally
1200                             },
1201                         ),
1202                     verticalAlignment = Alignment.CenterVertically,
1203                 ) {
1204                     Icon(Icons.Default.Close, contentDescription = null)
1205                     Text(text = stringResource(R.string.button_to_remove_widget))
1206                 }
1207             }
1208         }
1209 
1210         ToolbarButton(
1211             isPrimary = !removeEnabled,
1212             modifier = Modifier.align(Alignment.CenterEnd),
1213             onClick = onEditDone,
1214         ) {
1215             Icon(Icons.Default.Check, contentDescription = null)
1216             Text(text = stringResource(R.string.hub_mode_editing_exit_button_text))
1217         }
1218     }
1219 }
1220 
1221 /**
1222  * Toolbar button that displays as a filled button if primary, and an outline button if secondary.
1223  */
1224 @Composable
ToolbarButtonnull1225 private fun ToolbarButton(
1226     isPrimary: Boolean = true,
1227     onClick: () -> Unit,
1228     modifier: Modifier = Modifier,
1229     content: @Composable RowScope.() -> Unit,
1230 ) {
1231     val colors = MaterialTheme.colorScheme
1232     AnimatedVisibility(
1233         visible = isPrimary,
1234         modifier = modifier,
1235         enter = fadeIn(),
1236         exit = fadeOut(),
1237     ) {
1238         Button(
1239             onClick = onClick,
1240             colors = filledButtonColors(),
1241             contentPadding = Dimensions.ButtonPadding,
1242         ) {
1243             Row(
1244                 horizontalArrangement =
1245                     Arrangement.spacedBy(ButtonDefaults.IconSpacing, Alignment.CenterHorizontally),
1246                 verticalAlignment = Alignment.CenterVertically,
1247             ) {
1248                 content()
1249             }
1250         }
1251     }
1252 
1253     AnimatedVisibility(
1254         visible = !isPrimary,
1255         modifier = modifier,
1256         enter = fadeIn(),
1257         exit = fadeOut(),
1258     ) {
1259         OutlinedButton(
1260             onClick = onClick,
1261             colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.onPrimaryContainer),
1262             border = BorderStroke(width = 2.0.dp, color = colors.primary),
1263             contentPadding = Dimensions.ButtonPadding,
1264         ) {
1265             Row(
1266                 horizontalArrangement =
1267                     Arrangement.spacedBy(ButtonDefaults.IconSpacing, Alignment.CenterHorizontally),
1268                 verticalAlignment = Alignment.CenterVertically,
1269             ) {
1270                 content()
1271             }
1272         }
1273     }
1274 }
1275 
1276 @Composable
filledButtonColorsnull1277 private fun filledButtonColors(): ButtonColors {
1278     val colors = MaterialTheme.colorScheme
1279     return ButtonDefaults.buttonColors(
1280         containerColor = colors.primary,
1281         contentColor = colors.onPrimary,
1282     )
1283 }
1284 
1285 @Composable
CommunalContentnull1286 private fun CommunalContent(
1287     model: CommunalContentModel,
1288     viewModel: BaseCommunalViewModel,
1289     size: SizeF,
1290     selected: Boolean,
1291     modifier: Modifier = Modifier,
1292     widgetConfigurator: WidgetConfigurator? = null,
1293     index: Int,
1294     contentListState: ContentListState,
1295     interactionHandler: RemoteViews.InteractionHandler?,
1296     widgetSection: CommunalAppWidgetSection,
1297     resizeableItemFrameViewModel: ResizeableItemFrameViewModel,
1298     contentScope: ContentScope? = null,
1299 ) {
1300     when (model) {
1301         is CommunalContentModel.WidgetContent.Widget ->
1302             WidgetContent(
1303                 viewModel,
1304                 model,
1305                 size,
1306                 selected,
1307                 widgetConfigurator,
1308                 modifier,
1309                 index,
1310                 contentListState,
1311                 widgetSection,
1312                 resizeableItemFrameViewModel,
1313             )
1314         is CommunalContentModel.WidgetPlaceholder -> HighlightedItem(modifier)
1315         is CommunalContentModel.WidgetContent.DisabledWidget ->
1316             DisabledWidgetPlaceholder(model, viewModel, modifier)
1317         is CommunalContentModel.WidgetContent.PendingWidget ->
1318             PendingWidgetPlaceholder(model, modifier)
1319         is CommunalContentModel.CtaTileInViewMode -> CtaTileInViewModeContent(viewModel, modifier)
1320         is CommunalContentModel.Smartspace -> SmartspaceContent(interactionHandler, model, modifier)
1321         is CommunalContentModel.Tutorial -> TutorialContent(modifier)
1322         is CommunalContentModel.Umo -> Umo(viewModel, contentScope, modifier)
1323         is CommunalContentModel.Spacer -> Box(Modifier.fillMaxSize())
1324     }
1325 }
1326 
1327 /** Creates an empty card used to highlight a particular spot on the grid. */
1328 @Composable
HighlightedItemnull1329 fun HighlightedItem(modifier: Modifier = Modifier, alpha: Float = 1.0f) {
1330     val brush = SolidColor(MaterialTheme.colorScheme.primary)
1331     Box(
1332         modifier =
1333             // drawBehind lets us draw outside the bounds of the widgets so that we don't need to
1334             // resize grid items to account for the border.
1335             modifier.drawBehind {
1336                 // 8dp of padding between the widget and the highlight on every side.
1337                 val padding = 8.adjustedDp.toPx()
1338                 drawRoundRect(
1339                     brush,
1340                     alpha = alpha,
1341                     topLeft = Offset(-padding, -padding),
1342                     size =
1343                         Size(width = size.width + padding * 2, height = size.height + padding * 2),
1344                     cornerRadius = CornerRadius(37.adjustedDp.toPx()),
1345                     style = Stroke(width = 3.adjustedDp.toPx()),
1346                 )
1347             }
1348     )
1349 }
1350 
1351 /** Presents a CTA tile at the end of the grid, to customize the hub. */
1352 @Composable
CtaTileInViewModeContentnull1353 private fun CtaTileInViewModeContent(
1354     viewModel: BaseCommunalViewModel,
1355     modifier: Modifier = Modifier,
1356 ) {
1357     val colors = MaterialTheme.colorScheme
1358     Card(
1359         modifier = modifier,
1360         colors =
1361             CardDefaults.cardColors(
1362                 containerColor = colors.primary,
1363                 contentColor = colors.onPrimary,
1364             ),
1365         shape = RoundedCornerShape(68.adjustedDp, 34.adjustedDp, 68.adjustedDp, 34.adjustedDp),
1366     ) {
1367         Column(
1368             modifier =
1369                 Modifier.fillMaxSize()
1370                     .padding(vertical = 32.adjustedDp, horizontal = 50.adjustedDp),
1371             verticalArrangement = Arrangement.Center,
1372             horizontalAlignment = Alignment.CenterHorizontally,
1373         ) {
1374             Icon(
1375                 imageVector = Icons.Outlined.Widgets,
1376                 contentDescription = stringResource(R.string.cta_label_to_open_widget_picker),
1377                 modifier = Modifier.size(Dimensions.IconSize).clearAndSetSemantics {},
1378             )
1379             Spacer(modifier = Modifier.size(6.adjustedDp))
1380             Text(
1381                 text = stringResource(R.string.cta_label_to_edit_widget),
1382                 style = MaterialTheme.typography.titleLarge,
1383                 fontSize = nonScalableTextSize(22.dp),
1384                 lineHeight = nonScalableTextSize(28.dp),
1385                 modifier = Modifier.verticalScroll(rememberScrollState()).weight(1F),
1386             )
1387             Spacer(modifier = Modifier.size(16.adjustedDp))
1388             Row(
1389                 modifier = Modifier.fillMaxWidth().height(56.adjustedDp),
1390                 horizontalArrangement =
1391                     Arrangement.spacedBy(16.adjustedDp, Alignment.CenterHorizontally),
1392             ) {
1393                 CompositionLocalProvider(
1394                     LocalDensity provides
1395                         Density(
1396                             LocalDensity.current.density,
1397                             LocalDensity.current.fontScale.coerceIn(0f, 1.25f),
1398                         )
1399                 ) {
1400                     OutlinedButton(
1401                         modifier = Modifier.fillMaxHeight().weight(1F),
1402                         colors = ButtonDefaults.buttonColors(contentColor = colors.onPrimary),
1403                         border = BorderStroke(width = 1.0.dp, color = colors.primaryContainer),
1404                         onClick = viewModel::onDismissCtaTile,
1405                         contentPadding = PaddingValues(0.dp, 0.dp, 0.dp, 0.dp),
1406                     ) {
1407                         Text(
1408                             text = stringResource(R.string.cta_tile_button_to_dismiss),
1409                             fontSize = 14.sp,
1410                         )
1411                     }
1412                     Button(
1413                         modifier = Modifier.fillMaxHeight().weight(1F),
1414                         colors =
1415                             ButtonDefaults.buttonColors(
1416                                 containerColor = colors.primaryContainer,
1417                                 contentColor = colors.onPrimaryContainer,
1418                             ),
1419                         onClick = viewModel::onOpenWidgetEditor,
1420                         contentPadding = PaddingValues(0.dp, 0.dp, 0.dp, 0.dp),
1421                     ) {
1422                         Text(
1423                             text = stringResource(R.string.cta_tile_button_to_open_widget_editor),
1424                             fontSize = 14.sp,
1425                         )
1426                     }
1427                 }
1428             }
1429         }
1430     }
1431 }
1432 
1433 @Composable
WidgetContentnull1434 private fun WidgetContent(
1435     viewModel: BaseCommunalViewModel,
1436     model: CommunalContentModel.WidgetContent.Widget,
1437     size: SizeF,
1438     selected: Boolean,
1439     widgetConfigurator: WidgetConfigurator?,
1440     modifier: Modifier = Modifier,
1441     index: Int,
1442     contentListState: ContentListState,
1443     widgetSection: CommunalAppWidgetSection,
1444     resizeableItemFrameViewModel: ResizeableItemFrameViewModel,
1445 ) {
1446     val coroutineScope = rememberCoroutineScope()
1447     val context = LocalContext.current
1448     val accessibilityLabel =
1449         remember(model, context) {
1450             model.providerInfo.loadLabel(context.packageManager).toString().trim()
1451         }
1452     val clickActionLabel = stringResource(R.string.accessibility_action_label_select_widget)
1453     val removeWidgetActionLabel = stringResource(R.string.accessibility_action_label_remove_widget)
1454     val placeWidgetActionLabel = stringResource(R.string.accessibility_action_label_place_widget)
1455     val unselectWidgetActionLabel =
1456         stringResource(R.string.accessibility_action_label_unselect_widget)
1457 
1458     val shrinkWidgetLabel = stringResource(R.string.accessibility_action_label_shrink_widget)
1459     val expandWidgetLabel = stringResource(R.string.accessibility_action_label_expand_widget)
1460 
1461     val isFocusable by viewModel.isFocusable.collectAsStateWithLifecycle(initialValue = false)
1462     val selectedKey by viewModel.selectedKey.collectAsStateWithLifecycle()
1463     val selectedIndex =
1464         selectedKey?.let { key -> contentListState.list.indexOfFirst { it.key == key } }
1465 
1466     val interactionSource = remember { MutableInteractionSource() }
1467     val focusRequester = remember { FocusRequester() }
1468     if (viewModel.isEditMode && selected) {
1469         LaunchedEffect(Unit) {
1470             delay(TransitionDuration.BETWEEN_HUB_AND_EDIT_MODE_MS.toLong())
1471             focusRequester.requestFocus()
1472         }
1473     }
1474 
1475     val isSelected = selectedKey == model.key
1476 
1477     val selectableModifier =
1478         if (viewModel.isEditMode) {
1479             Modifier.selectable(
1480                 selected = isSelected,
1481                 onClick = { viewModel.setSelectedKey(model.key) },
1482                 interactionSource = interactionSource,
1483                 indication = null,
1484             )
1485         } else {
1486             Modifier
1487         }
1488     Box(
1489         modifier =
1490             modifier
1491                 .focusRequester(focusRequester)
1492                 .focusable(interactionSource = interactionSource)
1493                 .then(selectableModifier)
1494                 .thenIf(!viewModel.isEditMode && !model.inQuietMode) {
1495                     Modifier.pointerInput(Unit) {
1496                         observeTaps { viewModel.onTapWidget(model.componentName, model.rank) }
1497                     }
1498                 }
1499                 .thenIf(!viewModel.isEditMode && model.inQuietMode) {
1500                     Modifier.pointerInput(Unit) {
1501                         // consume tap to prevent the child view from triggering interactions with
1502                         // the app widget
1503                         observeTaps(shouldConsume = true) { _ ->
1504                             viewModel.onOpenEnableWorkProfileDialog()
1505                         }
1506                     }
1507                 }
1508                 .thenIf(viewModel.isEditMode) {
1509                     Modifier.semantics {
1510                         onClick(clickActionLabel, null)
1511                         contentDescription = accessibilityLabel
1512                         val deleteAction =
1513                             CustomAccessibilityAction(removeWidgetActionLabel) {
1514                                 contentListState.onRemove(index)
1515                                 contentListState.onSaveList()
1516                                 true
1517                             }
1518                         val actions = mutableListOf(deleteAction)
1519 
1520                         if (communalWidgetResizing() && resizeableItemFrameViewModel.canShrink()) {
1521                             actions.add(
1522                                 CustomAccessibilityAction(shrinkWidgetLabel) {
1523                                     coroutineScope.launch {
1524                                         resizeableItemFrameViewModel.shrinkToNextAnchor()
1525                                     }
1526                                     true
1527                                 }
1528                             )
1529                         }
1530 
1531                         if (communalWidgetResizing() && resizeableItemFrameViewModel.canExpand()) {
1532                             actions.add(
1533                                 CustomAccessibilityAction(expandWidgetLabel) {
1534                                     coroutineScope.launch {
1535                                         resizeableItemFrameViewModel.expandToNextAnchor()
1536                                     }
1537                                     true
1538                                 }
1539                             )
1540                         }
1541 
1542                         if (selectedIndex != null && selectedIndex != index) {
1543                             actions.add(
1544                                 CustomAccessibilityAction(placeWidgetActionLabel) {
1545                                     contentListState.onMove(selectedIndex!!, index)
1546                                     contentListState.onSaveList()
1547                                     viewModel.setSelectedKey(null)
1548                                     true
1549                                 }
1550                             )
1551                         }
1552 
1553                         if (!selected) {
1554                             actions.add(
1555                                 CustomAccessibilityAction(clickActionLabel) {
1556                                     viewModel.setSelectedKey(model.key)
1557                                     true
1558                                 }
1559                             )
1560                         } else {
1561                             actions.add(
1562                                 CustomAccessibilityAction(unselectWidgetActionLabel) {
1563                                     viewModel.setSelectedKey(null)
1564                                     true
1565                                 }
1566                             )
1567                         }
1568                         customActions = actions
1569                     }
1570                 }
1571     ) {
1572         with(widgetSection) {
1573             Widget(
1574                 isFocusable = isFocusable,
1575                 openWidgetEditor = {
1576                     viewModel.setSelectedKey(model.key)
1577                     viewModel.onOpenWidgetEditor()
1578                 },
1579                 model = model,
1580                 size = size,
1581                 modifier = Modifier.fillMaxSize().allowGestures(allowed = !viewModel.isEditMode),
1582             )
1583         }
1584         if (
1585             viewModel is CommunalEditModeViewModel &&
1586                 model.reconfigurable &&
1587                 widgetConfigurator != null
1588         ) {
1589             WidgetConfigureButton(
1590                 visible = selected,
1591                 model = model,
1592                 widgetConfigurator = widgetConfigurator,
1593                 modifier = Modifier.align(Alignment.BottomEnd),
1594             )
1595         }
1596     }
1597 }
1598 
1599 @Composable
WidgetConfigureButtonnull1600 fun WidgetConfigureButton(
1601     visible: Boolean,
1602     model: CommunalContentModel.WidgetContent.Widget,
1603     modifier: Modifier = Modifier,
1604     widgetConfigurator: WidgetConfigurator,
1605 ) {
1606     val colors = MaterialTheme.colorScheme
1607     val scope = rememberCoroutineScope()
1608 
1609     AnimatedVisibility(
1610         visible = visible,
1611         enter = fadeIn(),
1612         exit = fadeOut(),
1613         modifier = modifier.padding(16.adjustedDp),
1614     ) {
1615         FilledIconButton(
1616             shape = RoundedCornerShape(16.adjustedDp),
1617             modifier = Modifier.size(48.adjustedDp),
1618             colors =
1619                 IconButtonColors(
1620                     containerColor = colors.primary,
1621                     contentColor = colors.onPrimary,
1622                     disabledContainerColor = Color.Transparent,
1623                     disabledContentColor = Color.Transparent,
1624                 ),
1625             onClick = { scope.launch { widgetConfigurator.configureWidget(model.appWidgetId) } },
1626         ) {
1627             Icon(
1628                 imageVector = Icons.Outlined.Edit,
1629                 contentDescription = stringResource(id = R.string.edit_widget),
1630                 modifier = Modifier.padding(12.adjustedDp),
1631             )
1632         }
1633     }
1634 }
1635 
1636 @Composable
DisabledWidgetPlaceholdernull1637 fun DisabledWidgetPlaceholder(
1638     model: CommunalContentModel.WidgetContent.DisabledWidget,
1639     viewModel: BaseCommunalViewModel,
1640     modifier: Modifier = Modifier,
1641 ) {
1642     val context = LocalContext.current
1643     val appInfo = model.appInfo
1644     val icon: Icon =
1645         if (appInfo == null || appInfo.icon == 0) {
1646             Icon.createWithResource(context, android.R.drawable.sym_def_app_icon)
1647         } else {
1648             Icon.createWithResource(appInfo.packageName, appInfo.icon)
1649         }
1650 
1651     Column(
1652         modifier =
1653             modifier
1654                 .background(
1655                     color = MaterialTheme.colorScheme.surfaceVariant,
1656                     shape =
1657                         RoundedCornerShape(dimensionResource(system_app_widget_background_radius)),
1658                 )
1659                 .clickable(
1660                     enabled = !viewModel.isEditMode,
1661                     interactionSource = null,
1662                     indication = null,
1663                     onClick = viewModel::onOpenEnableWidgetDialog,
1664                 ),
1665         verticalArrangement = Arrangement.Center,
1666         horizontalAlignment = Alignment.CenterHorizontally,
1667     ) {
1668         Image(
1669             painter = rememberDrawablePainter(icon.loadDrawable(context)),
1670             contentDescription = stringResource(R.string.icon_description_for_disabled_widget),
1671             modifier = Modifier.size(Dimensions.IconSize),
1672             colorFilter = ColorFilter.colorMatrix(Colors.DisabledColorFilter),
1673         )
1674     }
1675 }
1676 
1677 @Composable
PendingWidgetPlaceholdernull1678 fun PendingWidgetPlaceholder(
1679     model: CommunalContentModel.WidgetContent.PendingWidget,
1680     modifier: Modifier = Modifier,
1681 ) {
1682     val context = LocalContext.current
1683     val icon: Icon =
1684         if (model.icon != null) {
1685             Icon.createWithBitmap(model.icon)
1686         } else {
1687             Icon.createWithResource(context, android.R.drawable.sym_def_app_icon)
1688         }
1689 
1690     Column(
1691         modifier =
1692             modifier.background(
1693                 color = MaterialTheme.colorScheme.surfaceVariant,
1694                 shape = RoundedCornerShape(dimensionResource(system_app_widget_background_radius)),
1695             ),
1696         verticalArrangement = Arrangement.Center,
1697         horizontalAlignment = Alignment.CenterHorizontally,
1698     ) {
1699         Image(
1700             painter = rememberDrawablePainter(icon.loadDrawable(context)),
1701             contentDescription = stringResource(R.string.icon_description_for_pending_widget),
1702             modifier = Modifier.size(Dimensions.IconSize),
1703         )
1704     }
1705 }
1706 
1707 @Composable
SmartspaceContentnull1708 private fun SmartspaceContent(
1709     interactionHandler: RemoteViews.InteractionHandler?,
1710     model: CommunalContentModel.Smartspace,
1711     modifier: Modifier = Modifier,
1712 ) {
1713     AndroidView(
1714         modifier = modifier,
1715         factory = { context ->
1716             SmartspaceAppWidgetHostView(context).apply {
1717                 interactionHandler?.let { setInteractionHandler(it) }
1718                 if (!communalTimerFlickerFix()) {
1719                     updateAppWidget(model.remoteViews)
1720                 }
1721             }
1722         },
1723         update =
1724             if (communalTimerFlickerFix()) {
1725                 { view: SmartspaceAppWidgetHostView -> view.updateAppWidget(model.remoteViews) }
1726             } else NoOpUpdate,
1727         // For reusing composition in lazy lists.
1728         onReset = {},
1729     )
1730 }
1731 
1732 @Composable
TutorialContentnull1733 private fun TutorialContent(modifier: Modifier = Modifier) {
1734     Card(modifier = modifier, content = {})
1735 }
1736 
1737 @Composable
Umonull1738 private fun Umo(
1739     viewModel: BaseCommunalViewModel,
1740     contentScope: ContentScope?,
1741     modifier: Modifier = Modifier,
1742 ) {
1743     val showNextActionLabel = stringResource(R.string.accessibility_action_label_umo_show_next)
1744     val showPreviousActionLabel =
1745         stringResource(R.string.accessibility_action_label_umo_show_previous)
1746 
1747     Box(
1748         modifier =
1749             modifier.thenIf(!viewModel.isEditMode) {
1750                 Modifier.semantics {
1751                     customActions =
1752                         listOf(
1753                             CustomAccessibilityAction(showNextActionLabel) {
1754                                 viewModel.onShowNextMedia()
1755                                 true
1756                             },
1757                             CustomAccessibilityAction(showPreviousActionLabel) {
1758                                 viewModel.onShowPreviousMedia()
1759                                 true
1760                             },
1761                         )
1762                 }
1763             }
1764     ) {
1765         if (SceneContainerFlag.isEnabled && contentScope != null) {
1766             contentScope.MediaCarousel(
1767                 modifier = modifier.fillMaxSize(),
1768                 isVisible = true,
1769                 mediaHost = viewModel.mediaHost,
1770                 carouselController = viewModel.mediaCarouselController,
1771             )
1772         } else {
1773             UmoLegacy(viewModel, modifier)
1774         }
1775     }
1776 }
1777 
1778 @Composable
UmoLegacynull1779 private fun UmoLegacy(viewModel: BaseCommunalViewModel, modifier: Modifier = Modifier) {
1780     AndroidView(
1781         modifier =
1782             modifier
1783                 .clip(
1784                     shape =
1785                         RoundedCornerShape(dimensionResource(R.dimen.notification_corner_radius))
1786                 )
1787                 .background(MaterialTheme.colorScheme.primary)
1788                 .pointerInput(Unit) {
1789                     detectHorizontalDragGestures { change, _ ->
1790                         change.consume()
1791                         val upTime = SystemClock.uptimeMillis()
1792                         val event =
1793                             MotionEvent.obtain(
1794                                 upTime,
1795                                 upTime,
1796                                 MotionEvent.ACTION_MOVE,
1797                                 change.position.x,
1798                                 change.position.y,
1799                                 0,
1800                             )
1801                         viewModel.mediaHost.hostView.dispatchTouchEvent(event)
1802                         event.recycle()
1803                     }
1804                 },
1805         factory = { _ ->
1806             viewModel.mediaHost.hostView.apply {
1807                 layoutParams =
1808                     FrameLayout.LayoutParams(
1809                         FrameLayout.LayoutParams.MATCH_PARENT,
1810                         FrameLayout.LayoutParams.MATCH_PARENT,
1811                     )
1812             }
1813             viewModel.mediaHost.hostView
1814         },
1815         onReset = {},
1816     )
1817 }
1818 
1819 /** Container of the glanceable hub grid to enable accessibility actions when focused. */
1820 @Composable
AccessibilityContainernull1821 fun AccessibilityContainer(viewModel: BaseCommunalViewModel, content: @Composable () -> Unit) {
1822     val context = LocalContext.current
1823     val isFocusable by viewModel.isFocusable.collectAsStateWithLifecycle(initialValue = false)
1824     Box(
1825         modifier =
1826             Modifier.fillMaxWidth().wrapContentHeight().thenIf(
1827                 isFocusable && !viewModel.isEditMode
1828             ) {
1829                 Modifier.focusable(isFocusable).semantics {
1830                     contentDescription =
1831                         context.getString(
1832                             R.string.accessibility_content_description_for_communal_hub
1833                         )
1834                     customActions =
1835                         listOf(
1836                             CustomAccessibilityAction(
1837                                 context.getString(
1838                                     R.string.accessibility_action_label_close_communal_hub
1839                                 )
1840                             ) {
1841                                 viewModel.changeScene(
1842                                     CommunalScenes.Blank,
1843                                     "closed by accessibility",
1844                                 )
1845                                 true
1846                             },
1847                             CustomAccessibilityAction(
1848                                 context.getString(R.string.accessibility_action_label_edit_widgets)
1849                             ) {
1850                                 viewModel.setSelectedKey(null)
1851                                 viewModel.onOpenWidgetEditor()
1852                                 true
1853                             },
1854                         )
1855                 }
1856             }
1857     ) {
1858         content()
1859     }
1860 }
1861 
1862 /**
1863  * Text size converted from dp value to the equivalent sp value using the current screen density,
1864  * ensuring it does not scale with the font size setting.
1865  */
1866 @Composable
<lambda>null1867 private fun nonScalableTextSize(sizeInDp: Dp) = with(LocalDensity.current) { sizeInDp.toSp() }
1868 
1869 /**
1870  * Returns the `contentPadding` of the grid. Use the vertical padding to push the grid content area
1871  * below the toolbar and let the grid take the max size. This ensures the item can be dragged
1872  * outside the grid over the toolbar, without part of it getting clipped by the container.
1873  */
1874 @Composable
gridContentPaddingnull1875 private fun gridContentPadding(isEditMode: Boolean, toolbarSize: IntSize?): PaddingValues {
1876     if (!isEditMode || toolbarSize == null) {
1877         return if (communalResponsiveGrid()) {
1878             val horizontalPaddings: Dp =
1879                 if (isCompactWindow()) {
1880                     horizontalPaddingWithInsets(Dimensions.ItemSpacingCompact)
1881                 } else {
1882                     Dimensions.ItemSpacing
1883                 }
1884             PaddingValues(start = horizontalPaddings, end = horizontalPaddings)
1885         } else {
1886             PaddingValues(
1887                 start = Dimensions.ItemSpacing,
1888                 end = Dimensions.ItemSpacing,
1889                 top = hubDimensions.GridTopSpacing,
1890             )
1891         }
1892     }
1893     val context = LocalContext.current
1894     val density = LocalDensity.current
1895     val windowMetrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context)
1896     val screenHeight = with(density) { windowMetrics.bounds.height().toDp() }
1897     val toolbarHeight = with(density) { Dimensions.ToolbarPaddingTop + toolbarSize.height.toDp() }
1898     return if (communalResponsiveGrid()) {
1899         PaddingValues(
1900             start = Dimensions.ToolbarPaddingHorizontal,
1901             end = Dimensions.ToolbarPaddingHorizontal,
1902             top = hubDimensions.GridTopSpacing,
1903         )
1904     } else {
1905         val verticalPadding =
1906             ((screenHeight - toolbarHeight - hubDimensions.GridHeight +
1907                     hubDimensions.GridTopSpacing) / 2)
1908                 .coerceAtLeast(Dimensions.Spacing)
1909         PaddingValues(
1910             start = Dimensions.ToolbarPaddingHorizontal,
1911             end = Dimensions.ToolbarPaddingHorizontal,
1912             top = verticalPadding + toolbarHeight,
1913             bottom = verticalPadding,
1914         )
1915     }
1916 }
1917 
1918 /** Compact size in landscape or portrait */
1919 @Composable
isCompactWindownull1920 fun isCompactWindow(): Boolean {
1921     val windowSizeClass = LocalWindowSizeClass.current
1922     return remember(windowSizeClass) {
1923         windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
1924             windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
1925     }
1926 }
1927 
CommunalContentSizenull1928 private fun CommunalContentSize.FixedSize.dp(): Dp {
1929     return when (this) {
1930         CommunalContentSize.FixedSize.FULL -> Dimensions.CardHeightFull
1931         CommunalContentSize.FixedSize.HALF -> Dimensions.CardHeightHalf
1932         CommunalContentSize.FixedSize.THIRD -> Dimensions.CardHeightThird
1933     }
1934 }
1935 
firstIndexAtOffsetnull1936 private fun firstIndexAtOffset(gridState: LazyGridState, offset: Offset): Int? =
1937     gridState.layoutInfo.visibleItemsInfo.firstItemAtOffset(offset)?.index
1938 
1939 /** Returns the key of item if it's editable at the given index. Only widget is editable. */
1940 private fun keyAtIndexIfEditable(list: List<CommunalContentModel>, index: Int): String? =
1941     if (index in list.indices && list[index].isWidgetContent()) list[index].key else null
1942 
1943 class Dimensions(val context: Context, val config: Configuration) {
1944     val GridTopSpacing: Dp
1945         get() {
1946             val result =
1947                 if (
1948                     communalResponsiveGrid() ||
1949                         config.orientation == Configuration.ORIENTATION_LANDSCAPE
1950                 ) {
1951                     114.dp
1952                 } else {
1953                     val windowMetrics =
1954                         WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context)
1955                     val density = context.resources.displayMetrics.density
1956                     val screenHeight = (windowMetrics.bounds.height() / density).dp
1957                     ((screenHeight - CardHeightFull) / 2)
1958                 }
1959             return result
1960         }
1961 
1962     val GridHeight: Dp
1963         get() = CardHeightFull + GridTopSpacing
1964 
1965     companion object {
1966         val CardHeightFull
1967             get() = 530.adjustedDp
1968 
1969         val ItemSpacingCompact
1970             get() = 12.adjustedDp
1971 
1972         val ItemSpacing
1973             get() = if (communalResponsiveGrid()) 32.adjustedDp else 50.adjustedDp
1974 
1975         val CardHeightHalf
1976             get() = (CardHeightFull - ItemSpacing) / 2
1977 
1978         val CardHeightThird
1979             get() = (CardHeightFull - (2 * ItemSpacing)) / 3
1980 
1981         val CardWidth
1982             get() = 360.adjustedDp
1983 
1984         val CardOutlineWidth
1985             get() = 3.adjustedDp
1986 
1987         val Spacing
1988             get() = ItemSpacing / 2
1989 
1990         // The sizing/padding of the toolbar in glanceable hub edit mode
1991         val ToolbarPaddingTop
1992             get() = 27.adjustedDp
1993 
1994         val ToolbarPaddingHorizontal
1995             get() = ItemSpacing
1996 
1997         val ToolbarButtonPaddingHorizontal
1998             get() = 24.adjustedDp
1999 
2000         val ToolbarButtonPaddingVertical
2001             get() = 16.adjustedDp
2002 
2003         val ButtonPadding =
2004             PaddingValues(
2005                 vertical = ToolbarButtonPaddingVertical,
2006                 horizontal = ToolbarButtonPaddingHorizontal,
2007             )
2008         val IconSize = 40.adjustedDp
2009         val SlideOffsetY = 30.adjustedDp
2010     }
2011 }
2012 
2013 data class WidgetSizeInfo(val minHeightPx: Int, val maxHeightPx: Int)
2014 
CommunalContentModelnull2015 private fun CommunalContentModel.getSpanOrMax(maxSpan: Int?) =
2016     if (maxSpan != null) {
2017         size.span.coerceAtMost(maxSpan)
2018     } else {
2019         size.span
2020     }
2021 
IntRectnull2022 private fun IntRect.percentOverlap(other: IntRect): Float {
2023     val intersection = intersect(other)
2024     if (intersection.width < 0 || intersection.height < 0) {
2025         return 0f
2026     }
2027     val overlapArea = intersection.width * intersection.height
2028     val area = width * height
2029     return overlapArea.toFloat() / area.toFloat()
2030 }
2031 
calculatePercentVisiblenull2032 private fun calculatePercentVisible(state: LazyGridState, index: Int): Float {
2033     val viewportSize = state.layoutInfo.viewportSize
2034     val visibleRect =
2035         IntRect(
2036             offset =
2037                 IntOffset(
2038                     state.layoutInfo.viewportStartOffset + state.layoutInfo.beforeContentPadding,
2039                     0,
2040                 ),
2041             size =
2042                 IntSize(
2043                     width =
2044                         viewportSize.width -
2045                             state.layoutInfo.beforeContentPadding -
2046                             state.layoutInfo.afterContentPadding,
2047                     height = viewportSize.height,
2048                 ),
2049         )
2050 
2051     val itemInfo = state.layoutInfo.visibleItemsInfo.find { it.index == index }
2052     return if (itemInfo != null) {
2053         val boundingBox = IntRect(itemInfo.offset, itemInfo.size)
2054         boundingBox.percentOverlap(visibleRect)
2055     } else {
2056         0f
2057     }
2058 }
2059 
2060 private object Colors {
<lambda>null2061     val DisabledColorFilter by lazy { disabledColorMatrix() }
2062 
2063     /** Returns the disabled image filter. Ported over from [DisableImageView]. */
disabledColorMatrixnull2064     private fun disabledColorMatrix(): ColorMatrix {
2065         val brightnessMatrix = ColorMatrix()
2066         val brightnessAmount = 0.5f
2067         val brightnessRgb = (255 * brightnessAmount).toInt().toFloat()
2068         // Brightness: C-new = C-old*(1-amount) + amount
2069         val scale = 1f - brightnessAmount
2070         val mat = brightnessMatrix.values
2071         mat[0] = scale
2072         mat[6] = scale
2073         mat[12] = scale
2074         mat[4] = brightnessRgb
2075         mat[9] = brightnessRgb
2076         mat[14] = brightnessRgb
2077 
2078         return ColorMatrix().apply {
2079             setToSaturation(0F)
2080             timesAssign(brightnessMatrix)
2081         }
2082     }
2083 }
2084 
2085 /** The resource id of communal hub accessible from UiAutomator. */
2086 private const val COMMUNAL_HUB_TEST_TAG = "communal_hub"
2087