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.graphics.drawable.Icon
20 import android.os.Bundle
21 import android.util.SizeF
22 import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
23 import android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
24 import android.widget.FrameLayout
25 import android.widget.RemoteViews
26 import androidx.annotation.VisibleForTesting
27 import androidx.compose.animation.AnimatedVisibility
28 import androidx.compose.animation.AnimatedVisibilityScope
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.layout.Arrangement
45 import androidx.compose.foundation.layout.Box
46 import androidx.compose.foundation.layout.BoxScope
47 import androidx.compose.foundation.layout.Column
48 import androidx.compose.foundation.layout.PaddingValues
49 import androidx.compose.foundation.layout.Row
50 import androidx.compose.foundation.layout.RowScope
51 import androidx.compose.foundation.layout.Spacer
52 import androidx.compose.foundation.layout.fillMaxSize
53 import androidx.compose.foundation.layout.fillMaxWidth
54 import androidx.compose.foundation.layout.height
55 import androidx.compose.foundation.layout.padding
56 import androidx.compose.foundation.layout.requiredSize
57 import androidx.compose.foundation.layout.size
58 import androidx.compose.foundation.layout.width
59 import androidx.compose.foundation.layout.wrapContentHeight
60 import androidx.compose.foundation.lazy.grid.GridCells
61 import androidx.compose.foundation.lazy.grid.GridItemSpan
62 import androidx.compose.foundation.lazy.grid.LazyGridState
63 import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
64 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
65 import androidx.compose.foundation.shape.RoundedCornerShape
66 import androidx.compose.material.icons.Icons
67 import androidx.compose.material.icons.filled.Add
68 import androidx.compose.material.icons.filled.Check
69 import androidx.compose.material.icons.filled.Close
70 import androidx.compose.material.icons.outlined.Edit
71 import androidx.compose.material.icons.outlined.TouchApp
72 import androidx.compose.material.icons.outlined.Widgets
73 import androidx.compose.material3.Button
74 import androidx.compose.material3.ButtonColors
75 import androidx.compose.material3.ButtonDefaults
76 import androidx.compose.material3.Card
77 import androidx.compose.material3.CardDefaults
78 import androidx.compose.material3.FilledIconButton
79 import androidx.compose.material3.Icon
80 import androidx.compose.material3.IconButtonColors
81 import androidx.compose.material3.MaterialTheme
82 import androidx.compose.material3.OutlinedButton
83 import androidx.compose.material3.Text
84 import androidx.compose.runtime.Composable
85 import androidx.compose.runtime.LaunchedEffect
86 import androidx.compose.runtime.State
87 import androidx.compose.runtime.derivedStateOf
88 import androidx.compose.runtime.getValue
89 import androidx.compose.runtime.mutableStateOf
90 import androidx.compose.runtime.remember
91 import androidx.compose.runtime.rememberCoroutineScope
92 import androidx.compose.runtime.setValue
93 import androidx.compose.ui.Alignment
94 import androidx.compose.ui.ExperimentalComposeUiApi
95 import androidx.compose.ui.Modifier
96 import androidx.compose.ui.geometry.Offset
97 import androidx.compose.ui.graphics.Color
98 import androidx.compose.ui.graphics.ColorFilter
99 import androidx.compose.ui.graphics.ColorMatrix
100 import androidx.compose.ui.graphics.TransformOrigin
101 import androidx.compose.ui.graphics.graphicsLayer
102 import androidx.compose.ui.input.key.onPreviewKeyEvent
103 import androidx.compose.ui.input.pointer.motionEventSpy
104 import androidx.compose.ui.input.pointer.pointerInput
105 import androidx.compose.ui.layout.LayoutCoordinates
106 import androidx.compose.ui.layout.boundsInWindow
107 import androidx.compose.ui.layout.onGloballyPositioned
108 import androidx.compose.ui.layout.onSizeChanged
109 import androidx.compose.ui.layout.positionInWindow
110 import androidx.compose.ui.platform.LocalContext
111 import androidx.compose.ui.platform.LocalDensity
112 import androidx.compose.ui.platform.testTag
113 import androidx.compose.ui.res.dimensionResource
114 import androidx.compose.ui.res.stringResource
115 import androidx.compose.ui.semantics.CustomAccessibilityAction
116 import androidx.compose.ui.semantics.contentDescription
117 import androidx.compose.ui.semantics.customActions
118 import androidx.compose.ui.semantics.onClick
119 import androidx.compose.ui.semantics.semantics
120 import androidx.compose.ui.semantics.testTagsAsResourceId
121 import androidx.compose.ui.text.style.TextAlign
122 import androidx.compose.ui.unit.Dp
123 import androidx.compose.ui.unit.IntOffset
124 import androidx.compose.ui.unit.IntSize
125 import androidx.compose.ui.unit.LayoutDirection
126 import androidx.compose.ui.unit.dp
127 import androidx.compose.ui.unit.sp
128 import androidx.compose.ui.unit.times
129 import androidx.compose.ui.viewinterop.AndroidView
130 import androidx.compose.ui.window.Popup
131 import androidx.lifecycle.compose.collectAsStateWithLifecycle
132 import androidx.window.layout.WindowMetricsCalculator
133 import com.android.compose.animation.Easings.Emphasized
134 import com.android.compose.modifiers.thenIf
135 import com.android.compose.theme.LocalAndroidColorScheme
136 import com.android.compose.ui.graphics.painter.rememberDrawablePainter
137 import com.android.internal.R.dimen.system_app_widget_background_radius
138 import com.android.systemui.communal.domain.model.CommunalContentModel
139 import com.android.systemui.communal.shared.model.CommunalContentSize
140 import com.android.systemui.communal.shared.model.CommunalScenes
141 import com.android.systemui.communal.ui.compose.Dimensions.CardOutlineWidth
142 import com.android.systemui.communal.ui.compose.extensions.allowGestures
143 import com.android.systemui.communal.ui.compose.extensions.detectLongPressGesture
144 import com.android.systemui.communal.ui.compose.extensions.firstItemAtOffset
145 import com.android.systemui.communal.ui.compose.extensions.observeTaps
146 import com.android.systemui.communal.ui.viewmodel.BaseCommunalViewModel
147 import com.android.systemui.communal.ui.viewmodel.CommunalEditModeViewModel
148 import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
149 import com.android.systemui.communal.ui.viewmodel.PopupType
150 import com.android.systemui.communal.widgets.SmartspaceAppWidgetHostView
151 import com.android.systemui.communal.widgets.WidgetConfigurator
152 import com.android.systemui.res.R
153 import com.android.systemui.statusbar.phone.SystemUIDialogFactory
154 import kotlinx.coroutines.launch
155 
156 @OptIn(ExperimentalComposeUiApi::class)
157 @Composable
158 fun CommunalHub(
159     modifier: Modifier = Modifier,
160     viewModel: BaseCommunalViewModel,
161     interactionHandler: RemoteViews.InteractionHandler? = null,
162     dialogFactory: SystemUIDialogFactory? = null,
163     widgetConfigurator: WidgetConfigurator? = null,
164     onOpenWidgetPicker: (() -> Unit)? = null,
165     onEditDone: (() -> Unit)? = null,
166 ) {
167     val communalContent by
168         viewModel.communalContent.collectAsStateWithLifecycle(initialValue = emptyList())
169     val currentPopup by viewModel.currentPopup.collectAsStateWithLifecycle(initialValue = null)
170     var removeButtonCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
171     var toolbarSize: IntSize? by remember { mutableStateOf(null) }
172     var gridCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
173     val gridState = rememberLazyGridState()
174     val contentListState = rememberContentListState(widgetConfigurator, communalContent, viewModel)
175     val reorderingWidgets by viewModel.reorderingWidgets.collectAsStateWithLifecycle()
176     val selectedKey = viewModel.selectedKey.collectAsStateWithLifecycle()
177     val removeButtonEnabled by remember {
178         derivedStateOf { selectedKey.value != null || reorderingWidgets }
179     }
180     val isEmptyState by viewModel.isEmptyState.collectAsStateWithLifecycle(initialValue = false)
181     val isCommunalContentVisible by
182         viewModel.isCommunalContentVisible.collectAsStateWithLifecycle(
183             initialValue = !viewModel.isEditMode
184         )
185 
186     val contentPadding = gridContentPadding(viewModel.isEditMode, toolbarSize)
187     val contentOffset = beforeContentPadding(contentPadding).toOffset()
188 
189     if (!viewModel.isEditMode) {
190         ScrollOnUpdatedLiveContentEffect(communalContent, gridState)
191     }
192 
193     Box(
194         modifier =
195             modifier
196                 .semantics { testTagsAsResourceId = true }
197                 .testTag(COMMUNAL_HUB_TEST_TAG)
198                 .fillMaxSize()
199                 .pointerInput(gridState, contentOffset, contentListState) {
200                     // If not in edit mode, don't allow selecting items.
201                     if (!viewModel.isEditMode) return@pointerInput
202                     observeTaps { offset ->
203                         val adjustedOffset = offset - contentOffset
204                         val index = firstIndexAtOffset(gridState, adjustedOffset)
205                         val key = index?.let { keyAtIndexIfEditable(contentListState.list, index) }
206                         viewModel.setSelectedKey(key)
207                     }
208                 }
209                 .thenIf(!viewModel.isEditMode && !isEmptyState) {
210                     Modifier.pointerInput(
211                             gridState,
212                             contentOffset,
213                             communalContent,
214                             gridCoordinates
215                         ) {
216                             detectLongPressGesture { offset ->
217                                 // Deduct both grid offset relative to its container and content
218                                 // offset.
219                                 val adjustedOffset =
220                                     gridCoordinates?.let {
221                                         offset - it.positionInWindow() - contentOffset
222                                     }
223                                 val index =
224                                     adjustedOffset?.let { firstIndexAtOffset(gridState, it) }
225                                 // Display the button only when the gesture initiates from widgets,
226                                 // the CTA tile, or an empty area on the screen. UMO/smartspace have
227                                 // their own long-press handlers. To prevent user confusion, we
228                                 // should
229                                 // not display this button.
230                                 if (
231                                     index == null ||
232                                         communalContent[index].isWidgetContent() ||
233                                         communalContent[index] is
234                                             CommunalContentModel.CtaTileInViewMode
235                                 ) {
236                                     viewModel.onShowCustomizeWidgetButton()
237                                 }
238                                 val key =
239                                     index?.let { keyAtIndexIfEditable(communalContent, index) }
240                                 viewModel.setSelectedKey(key)
241                             }
242                         }
243                         .onPreviewKeyEvent {
244                             onKeyEvent(viewModel)
245                             false
246                         }
247                         .motionEventSpy { onMotionEvent(viewModel) }
248                 },
249     ) {
250         AccessibilityContainer(viewModel) {
251             if (!viewModel.isEditMode && isEmptyState) {
252                 EmptyStateCta(
253                     contentPadding = contentPadding,
254                     viewModel = viewModel,
255                 )
256             } else {
257                 val slideOffsetInPx =
258                     with(LocalDensity.current) { Dimensions.SlideOffsetY.toPx().toInt() }
259                 AnimatedVisibility(
260                     visible = isCommunalContentVisible,
261                     enter =
262                         fadeIn(
263                             animationSpec =
264                                 tween(durationMillis = 83, delayMillis = 83, easing = LinearEasing)
265                         ) +
266                             slideInVertically(
267                                 animationSpec = tween(durationMillis = 1000, easing = Emphasized),
268                                 initialOffsetY = { -slideOffsetInPx }
269                             ),
270                     exit =
271                         fadeOut(
272                             animationSpec = tween(durationMillis = 167, easing = LinearEasing)
273                         ) +
274                             slideOutVertically(
275                                 animationSpec = tween(durationMillis = 1000, easing = Emphasized),
276                                 targetOffsetY = { -slideOffsetInPx }
277                             ),
278                     modifier = Modifier.fillMaxSize(),
279                 ) {
280                     Box {
281                         CommunalHubLazyGrid(
282                             communalContent = communalContent,
283                             viewModel = viewModel,
284                             contentPadding = contentPadding,
285                             contentOffset = contentOffset,
286                             setGridCoordinates = { gridCoordinates = it },
287                             updateDragPositionForRemove = { offset ->
288                                 isPointerWithinEnabledRemoveButton(
289                                     removeEnabled = removeButtonEnabled,
290                                     offset =
291                                         gridCoordinates?.let { it.positionInWindow() + offset },
292                                     containerToCheck = removeButtonCoordinates
293                                 )
294                             },
295                             gridState = gridState,
296                             contentListState = contentListState,
297                             selectedKey = selectedKey,
298                             widgetConfigurator = widgetConfigurator,
299                             interactionHandler = interactionHandler,
300                         )
301                     }
302                 }
303             }
304         }
305 
306         if (onOpenWidgetPicker != null && onEditDone != null) {
307             AnimatedVisibility(
308                 visible = viewModel.isEditMode && isCommunalContentVisible,
309                 enter =
310                     fadeIn(animationSpec = tween(durationMillis = 250, easing = LinearEasing)) +
311                         slideInVertically(
312                             animationSpec = tween(durationMillis = 1000, easing = Emphasized),
313                         ),
314                 exit =
315                     fadeOut(animationSpec = tween(durationMillis = 167, easing = LinearEasing)) +
316                         slideOutVertically(
317                             animationSpec = tween(durationMillis = 1000, easing = Emphasized)
318                         ),
319             ) {
320                 Toolbar(
321                     setToolbarSize = { toolbarSize = it },
322                     setRemoveButtonCoordinates = { removeButtonCoordinates = it },
323                     onEditDone = onEditDone,
324                     onOpenWidgetPicker = onOpenWidgetPicker,
325                     onRemoveClicked = {
326                         val index =
327                             selectedKey.value?.let { key ->
328                                 contentListState.list.indexOfFirst { it.key == key }
329                             }
330                         index?.let {
331                             contentListState.onRemove(it)
332                             contentListState.onSaveList()
333                             viewModel.setSelectedKey(null)
334                         }
335                     },
336                     removeEnabled = removeButtonEnabled
337                 )
338             }
339         }
340         if (currentPopup == PopupType.CtaTile) {
341             PopupOnDismissCtaTile(viewModel::onHidePopup)
342         }
343 
344         AnimatedVisibility(
345             visible = currentPopup == PopupType.CustomizeWidgetButton,
346             modifier = Modifier.fillMaxSize()
347         ) {
348             ButtonToEditWidgets(
349                 onClick = {
350                     viewModel.onHidePopup()
351                     viewModel.onOpenWidgetEditor(selectedKey.value)
352                 },
353                 onHide = { viewModel.onHidePopup() }
354             )
355         }
356 
357         if (viewModel is CommunalViewModel && dialogFactory != null) {
358             val isEnableWidgetDialogShowing by
359                 viewModel.isEnableWidgetDialogShowing.collectAsStateWithLifecycle(false)
360             val isEnableWorkProfileDialogShowing by
361                 viewModel.isEnableWorkProfileDialogShowing.collectAsStateWithLifecycle(false)
362 
363             EnableWidgetDialog(
364                 isEnableWidgetDialogVisible = isEnableWidgetDialogShowing,
365                 dialogFactory = dialogFactory,
366                 title = stringResource(id = R.string.dialog_title_to_allow_any_widget),
367                 positiveButtonText = stringResource(id = R.string.button_text_to_open_settings),
368                 onConfirm = viewModel::onEnableWidgetDialogConfirm,
369                 onCancel = viewModel::onEnableWidgetDialogCancel
370             )
371 
372             EnableWidgetDialog(
373                 isEnableWidgetDialogVisible = isEnableWorkProfileDialogShowing,
374                 dialogFactory = dialogFactory,
375                 title = stringResource(id = R.string.work_mode_off_title),
376                 positiveButtonText = stringResource(id = R.string.work_mode_turn_on),
377                 onConfirm = viewModel::onEnableWorkProfileDialogConfirm,
378                 onCancel = viewModel::onEnableWorkProfileDialogCancel
379             )
380         }
381 
382         // This spacer covers the edge of the LazyHorizontalGrid and prevents it from receiving
383         // touches, so that the SceneTransitionLayout can intercept the touches and allow an edge
384         // swipe back to the blank scene.
385         Spacer(
386             Modifier.height(Dimensions.GridHeight)
387                 .align(Alignment.CenterStart)
388                 .width(Dimensions.Spacing)
389                 .pointerInput(Unit) {}
390         )
391     }
392 }
393 
onKeyEventnull394 private fun onKeyEvent(viewModel: BaseCommunalViewModel) {
395     viewModel.signalUserInteraction()
396 }
397 
onMotionEventnull398 private fun onMotionEvent(viewModel: BaseCommunalViewModel) {
399     viewModel.signalUserInteraction()
400 }
401 
402 /**
403  * Observes communal content and scrolls to any added or updated live content, e.g. a new media
404  * session is started, or a paused timer is resumed.
405  */
406 @Composable
ScrollOnUpdatedLiveContentEffectnull407 private fun ScrollOnUpdatedLiveContentEffect(
408     communalContent: List<CommunalContentModel>,
409     gridState: LazyGridState,
410 ) {
411     val coroutineScope = rememberCoroutineScope()
412     val liveContentKeys = remember { mutableListOf<String>() }
413 
414     LaunchedEffect(communalContent) {
415         val prevLiveContentKeys = liveContentKeys.toList()
416         liveContentKeys.clear()
417         liveContentKeys.addAll(communalContent.filter { it.isLiveContent() }.map { it.key })
418 
419         // Find the first updated content
420         val indexOfFirstUpdatedContent =
421             liveContentKeys.indexOfFirst { !prevLiveContentKeys.contains(it) }
422 
423         // Scroll if current position is behind the first updated content
424         if (indexOfFirstUpdatedContent in 0 until gridState.firstVisibleItemIndex) {
425             // Launching with a scope to prevent the job from being canceled in the case of a
426             // recomposition during scrolling
427             coroutineScope.launch { gridState.animateScrollToItem(indexOfFirstUpdatedContent) }
428         }
429     }
430 }
431 
432 @OptIn(ExperimentalFoundationApi::class)
433 @Composable
CommunalHubLazyGridnull434 private fun BoxScope.CommunalHubLazyGrid(
435     communalContent: List<CommunalContentModel>,
436     viewModel: BaseCommunalViewModel,
437     contentPadding: PaddingValues,
438     selectedKey: State<String?>,
439     contentOffset: Offset,
440     gridState: LazyGridState,
441     contentListState: ContentListState,
442     setGridCoordinates: (coordinates: LayoutCoordinates) -> Unit,
443     updateDragPositionForRemove: (offset: Offset) -> Boolean,
444     widgetConfigurator: WidgetConfigurator?,
445     interactionHandler: RemoteViews.InteractionHandler?,
446 ) {
447     var gridModifier =
448         Modifier.align(Alignment.TopStart).onGloballyPositioned { setGridCoordinates(it) }
449     var list = communalContent
450     var dragDropState: GridDragDropState? = null
451     if (viewModel.isEditMode && viewModel is CommunalEditModeViewModel) {
452         list = contentListState.list
453         // for drag & drop operations within the communal hub grid
454         dragDropState =
455             rememberGridDragDropState(
456                 gridState = gridState,
457                 contentListState = contentListState,
458                 updateDragPositionForRemove = updateDragPositionForRemove
459             )
460         gridModifier =
461             gridModifier.fillMaxSize().dragContainer(dragDropState, contentOffset, viewModel)
462         // for widgets dropped from other activities
463         val dragAndDropTargetState =
464             rememberDragAndDropTargetState(
465                 gridState = gridState,
466                 contentListState = contentListState,
467                 updateDragPositionForRemove = updateDragPositionForRemove
468             )
469 
470         // A full size box in background that listens to widget drops from the picker.
471         // Since the grid has its own listener for in-grid drag events, we use a separate element
472         // for android drag events.
473         Box(Modifier.fillMaxSize().dragAndDropTarget(dragAndDropTargetState)) {}
474     } else {
475         gridModifier = gridModifier.height(Dimensions.GridHeight)
476     }
477 
478     LazyHorizontalGrid(
479         modifier = gridModifier,
480         state = gridState,
481         rows = GridCells.Fixed(CommunalContentSize.FULL.span),
482         contentPadding = contentPadding,
483         horizontalArrangement = Arrangement.spacedBy(Dimensions.ItemSpacing),
484         verticalArrangement = Arrangement.spacedBy(Dimensions.ItemSpacing),
485     ) {
486         items(
487             count = list.size,
488             key = { index -> list[index].key },
489             contentType = { index -> list[index].key },
490             span = { index -> GridItemSpan(list[index].size.span) },
491         ) { index ->
492             val size =
493                 SizeF(
494                     Dimensions.CardWidth.value,
495                     list[index].size.dp().value,
496                 )
497             val cardModifier = Modifier.requiredSize(width = size.width.dp, height = size.height.dp)
498             if (viewModel.isEditMode && dragDropState != null) {
499                 val selected by
500                     remember(index) { derivedStateOf { list[index].key == selectedKey.value } }
501                 DraggableItem(
502                     modifier =
503                         if (dragDropState.draggingItemIndex == index) {
504                             Modifier
505                         } else {
506                             Modifier.animateItem(
507                                 placementSpec = spring(stiffness = Spring.StiffnessMediumLow)
508                             )
509                         },
510                     dragDropState = dragDropState,
511                     selected = selected,
512                     enabled = list[index].isWidgetContent(),
513                     index = index,
514                 ) { isDragging ->
515                     CommunalContent(
516                         modifier = cardModifier,
517                         model = list[index],
518                         viewModel = viewModel,
519                         size = size,
520                         selected = selected && !isDragging,
521                         widgetConfigurator = widgetConfigurator,
522                         index = index,
523                         contentListState = contentListState,
524                         interactionHandler = interactionHandler,
525                     )
526                 }
527             } else {
528                 CommunalContent(
529                     modifier = cardModifier.animateItemPlacement(),
530                     model = list[index],
531                     viewModel = viewModel,
532                     size = size,
533                     selected = false,
534                     index = index,
535                     contentListState = contentListState,
536                     interactionHandler = interactionHandler,
537                 )
538             }
539         }
540     }
541 }
542 
543 /**
544  * The empty state displays a fullscreen call-to-action (CTA) tile when no widgets are available.
545  */
546 @Composable
EmptyStateCtanull547 private fun EmptyStateCta(
548     contentPadding: PaddingValues,
549     viewModel: BaseCommunalViewModel,
550 ) {
551     val colors = LocalAndroidColorScheme.current
552     Card(
553         modifier = Modifier.height(Dimensions.GridHeight).padding(contentPadding),
554         colors = CardDefaults.cardColors(containerColor = Color.Transparent),
555         border = BorderStroke(3.dp, colors.secondary),
556         shape = RoundedCornerShape(size = 80.dp)
557     ) {
558         Column(
559             modifier = Modifier.fillMaxSize().padding(horizontal = 110.dp),
560             verticalArrangement =
561                 Arrangement.spacedBy(Dimensions.Spacing, Alignment.CenterVertically),
562             horizontalAlignment = Alignment.CenterHorizontally,
563         ) {
564             Text(
565                 text = stringResource(R.string.title_for_empty_state_cta),
566                 style = MaterialTheme.typography.displaySmall,
567                 textAlign = TextAlign.Center,
568                 color = colors.secondary,
569             )
570             Row(
571                 modifier = Modifier.fillMaxWidth(),
572                 horizontalArrangement = Arrangement.Center,
573             ) {
574                 Button(
575                     modifier = Modifier.height(56.dp),
576                     colors =
577                         ButtonDefaults.buttonColors(
578                             containerColor = colors.primary,
579                             contentColor = colors.onPrimary,
580                         ),
581                     onClick = {
582                         viewModel.onOpenWidgetEditor(
583                             shouldOpenWidgetPickerOnStart = true,
584                         )
585                     },
586                 ) {
587                     Icon(
588                         imageVector = Icons.Default.Add,
589                         contentDescription =
590                             stringResource(R.string.label_for_button_in_empty_state_cta),
591                         modifier = Modifier.size(24.dp)
592                     )
593                     Spacer(Modifier.width(ButtonDefaults.IconSpacing))
594                     Text(
595                         text = stringResource(R.string.label_for_button_in_empty_state_cta),
596                         style = MaterialTheme.typography.titleSmall,
597                     )
598                 }
599             }
600         }
601     }
602 }
603 
604 /**
605  * Toolbar that contains action buttons to
606  * 1) open the widget picker
607  * 2) remove a widget from the grid and
608  * 3) exit the edit mode.
609  */
610 @Composable
Toolbarnull611 private fun Toolbar(
612     removeEnabled: Boolean,
613     onRemoveClicked: () -> Unit,
614     setToolbarSize: (toolbarSize: IntSize) -> Unit,
615     setRemoveButtonCoordinates: (coordinates: LayoutCoordinates) -> Unit,
616     onOpenWidgetPicker: () -> Unit,
617     onEditDone: () -> Unit
618 ) {
619     val removeButtonAlpha: Float by
620         animateFloatAsState(
621             targetValue = if (removeEnabled) 1f else 0.5f,
622             label = "RemoveButtonAlphaAnimation"
623         )
624 
625     Box(
626         modifier =
627             Modifier.fillMaxWidth()
628                 .padding(
629                     top = Dimensions.ToolbarPaddingTop,
630                     start = Dimensions.ToolbarPaddingHorizontal,
631                     end = Dimensions.ToolbarPaddingHorizontal,
632                 )
633                 .onSizeChanged { setToolbarSize(it) },
634     ) {
635         ToolbarButton(
636             isPrimary = !removeEnabled,
637             modifier = Modifier.align(Alignment.CenterStart),
638             onClick = onOpenWidgetPicker,
639         ) {
640             Icon(Icons.Default.Add, stringResource(R.string.hub_mode_add_widget_button_text))
641             Text(
642                 text = stringResource(R.string.hub_mode_add_widget_button_text),
643             )
644         }
645 
646         AnimatedVisibility(
647             modifier = Modifier.align(Alignment.Center),
648             visible = removeEnabled,
649             enter = fadeIn(),
650             exit = fadeOut()
651         ) {
652             Button(
653                 onClick = onRemoveClicked,
654                 colors = filledButtonColors(),
655                 contentPadding = Dimensions.ButtonPadding,
656                 modifier =
657                     Modifier.graphicsLayer { alpha = removeButtonAlpha }
658                         .onGloballyPositioned { setRemoveButtonCoordinates(it) }
659             ) {
660                 Row(
661                     horizontalArrangement =
662                         Arrangement.spacedBy(
663                             ButtonDefaults.IconSpacing,
664                             Alignment.CenterHorizontally
665                         ),
666                     verticalAlignment = Alignment.CenterVertically
667                 ) {
668                     Icon(Icons.Default.Close, stringResource(R.string.button_to_remove_widget))
669                     Text(
670                         text = stringResource(R.string.button_to_remove_widget),
671                     )
672                 }
673             }
674         }
675 
676         ToolbarButton(
677             isPrimary = !removeEnabled,
678             modifier = Modifier.align(Alignment.CenterEnd),
679             onClick = onEditDone,
680         ) {
681             Icon(
682                 Icons.Default.Check,
683                 stringResource(id = R.string.hub_mode_editing_exit_button_text)
684             )
685             Text(
686                 text = stringResource(R.string.hub_mode_editing_exit_button_text),
687             )
688         }
689     }
690 }
691 
692 /**
693  * Toolbar button that displays as a filled button if primary, and an outline button if secondary.
694  */
695 @Composable
ToolbarButtonnull696 private fun ToolbarButton(
697     isPrimary: Boolean = true,
698     onClick: () -> Unit,
699     modifier: Modifier = Modifier,
700     content: @Composable RowScope.() -> Unit
701 ) {
702     val colors = LocalAndroidColorScheme.current
703     AnimatedVisibility(
704         visible = isPrimary,
705         modifier = modifier,
706         enter = fadeIn(),
707         exit = fadeOut()
708     ) {
709         Button(
710             onClick = onClick,
711             colors = filledButtonColors(),
712             contentPadding = Dimensions.ButtonPadding,
713         ) {
714             Row(
715                 horizontalArrangement =
716                     Arrangement.spacedBy(ButtonDefaults.IconSpacing, Alignment.CenterHorizontally),
717                 verticalAlignment = Alignment.CenterVertically
718             ) {
719                 content()
720             }
721         }
722     }
723 
724     AnimatedVisibility(
725         visible = !isPrimary,
726         modifier = modifier,
727         enter = fadeIn(),
728         exit = fadeOut()
729     ) {
730         OutlinedButton(
731             onClick = onClick,
732             colors =
733                 ButtonDefaults.outlinedButtonColors(
734                     contentColor = colors.primary,
735                 ),
736             border = BorderStroke(width = 2.0.dp, color = colors.primary),
737             contentPadding = Dimensions.ButtonPadding,
738         ) {
739             Row(
740                 horizontalArrangement =
741                     Arrangement.spacedBy(ButtonDefaults.IconSpacing, Alignment.CenterHorizontally),
742                 verticalAlignment = Alignment.CenterVertically
743             ) {
744                 content()
745             }
746         }
747     }
748 }
749 
750 @Composable
ButtonToEditWidgetsnull751 private fun AnimatedVisibilityScope.ButtonToEditWidgets(
752     onClick: () -> Unit,
753     onHide: () -> Unit,
754 ) {
755     Popup(
756         alignment = Alignment.TopCenter,
757         offset = IntOffset(0, 40),
758         onDismissRequest = onHide,
759     ) {
760         val colors = LocalAndroidColorScheme.current
761         Button(
762             modifier =
763                 Modifier.height(56.dp)
764                     .graphicsLayer { transformOrigin = TransformOrigin(0f, 0f) }
765                     .animateEnterExit(
766                         enter =
767                             fadeIn(
768                                 initialAlpha = 0f,
769                                 animationSpec = tween(durationMillis = 83, easing = LinearEasing)
770                             ),
771                         exit =
772                             fadeOut(
773                                 animationSpec =
774                                     tween(
775                                         durationMillis = 83,
776                                         delayMillis = 167,
777                                         easing = LinearEasing
778                                     )
779                             )
780                     )
781                     .background(colors.secondary, RoundedCornerShape(50.dp)),
782             onClick = onClick,
783         ) {
784             Row(
785                 modifier =
786                     Modifier.animateEnterExit(
787                         enter =
788                             fadeIn(
789                                 animationSpec =
790                                     tween(
791                                         durationMillis = 167,
792                                         delayMillis = 83,
793                                         easing = LinearEasing
794                                     )
795                             ),
796                         exit =
797                             fadeOut(
798                                 animationSpec = tween(durationMillis = 167, easing = LinearEasing)
799                             )
800                     )
801             ) {
802                 Icon(
803                     imageVector = Icons.Outlined.Widgets,
804                     contentDescription = stringResource(R.string.button_to_configure_widgets_text),
805                     tint = colors.onSecondary,
806                     modifier = Modifier.size(20.dp)
807                 )
808                 Spacer(modifier = Modifier.size(8.dp))
809                 Text(
810                     text = stringResource(R.string.button_to_configure_widgets_text),
811                     style = MaterialTheme.typography.titleSmall,
812                     color = colors.onSecondary
813                 )
814             }
815         }
816     }
817 }
818 
819 @Composable
PopupOnDismissCtaTilenull820 private fun PopupOnDismissCtaTile(onHidePopup: () -> Unit) {
821     Popup(
822         alignment = Alignment.TopCenter,
823         offset = IntOffset(0, 40),
824         onDismissRequest = onHidePopup
825     ) {
826         val colors = LocalAndroidColorScheme.current
827         Row(
828             modifier =
829                 Modifier.height(56.dp)
830                     .background(colors.secondary, RoundedCornerShape(50.dp))
831                     .padding(16.dp),
832             horizontalArrangement = Arrangement.Center,
833             verticalAlignment = Alignment.CenterVertically,
834         ) {
835             Icon(
836                 imageVector = Icons.Outlined.TouchApp,
837                 contentDescription = stringResource(R.string.popup_on_dismiss_cta_tile_text),
838                 tint = colors.onSecondary,
839                 modifier = Modifier.size(20.dp)
840             )
841             Spacer(modifier = Modifier.size(8.dp))
842             Text(
843                 text = stringResource(R.string.popup_on_dismiss_cta_tile_text),
844                 style = MaterialTheme.typography.titleSmall,
845                 color = colors.onSecondary,
846             )
847         }
848     }
849 }
850 
851 @Composable
filledButtonColorsnull852 private fun filledButtonColors(): ButtonColors {
853     val colors = LocalAndroidColorScheme.current
854     return ButtonDefaults.buttonColors(
855         containerColor = colors.primary,
856         contentColor = colors.onPrimary,
857     )
858 }
859 
860 @Composable
CommunalContentnull861 private fun CommunalContent(
862     model: CommunalContentModel,
863     viewModel: BaseCommunalViewModel,
864     size: SizeF,
865     selected: Boolean,
866     modifier: Modifier = Modifier,
867     widgetConfigurator: WidgetConfigurator? = null,
868     index: Int,
869     contentListState: ContentListState,
870     interactionHandler: RemoteViews.InteractionHandler?,
871 ) {
872     when (model) {
873         is CommunalContentModel.WidgetContent.Widget ->
874             WidgetContent(
875                 viewModel,
876                 model,
877                 size,
878                 selected,
879                 widgetConfigurator,
880                 modifier,
881                 index,
882                 contentListState
883             )
884         is CommunalContentModel.WidgetPlaceholder -> HighlightedItem(modifier)
885         is CommunalContentModel.WidgetContent.DisabledWidget ->
886             DisabledWidgetPlaceholder(model, viewModel, modifier)
887         is CommunalContentModel.WidgetContent.PendingWidget ->
888             PendingWidgetPlaceholder(model, modifier)
889         is CommunalContentModel.CtaTileInViewMode -> CtaTileInViewModeContent(viewModel, modifier)
890         is CommunalContentModel.Smartspace -> SmartspaceContent(interactionHandler, model, modifier)
891         is CommunalContentModel.Tutorial -> TutorialContent(modifier)
892         is CommunalContentModel.Umo -> Umo(viewModel, modifier)
893     }
894 }
895 
896 /** Creates an empty card used to highlight a particular spot on the grid. */
897 @Composable
HighlightedItemnull898 fun HighlightedItem(modifier: Modifier = Modifier) {
899     Card(
900         modifier = modifier,
901         colors = CardDefaults.cardColors(containerColor = Color.Transparent),
902         border = BorderStroke(CardOutlineWidth, LocalAndroidColorScheme.current.tertiaryFixed),
903         shape = RoundedCornerShape(16.dp)
904     ) {}
905 }
906 
907 /** Presents a CTA tile at the end of the grid, to customize the hub. */
908 @Composable
CtaTileInViewModeContentnull909 private fun CtaTileInViewModeContent(
910     viewModel: BaseCommunalViewModel,
911     modifier: Modifier = Modifier,
912 ) {
913     val colors = LocalAndroidColorScheme.current
914     Card(
915         modifier = modifier,
916         colors =
917             CardDefaults.cardColors(
918                 containerColor = colors.primary,
919                 contentColor = colors.onPrimary,
920             ),
921         shape = RoundedCornerShape(68.dp, 34.dp, 68.dp, 34.dp)
922     ) {
923         Column(
924             modifier = Modifier.fillMaxSize().padding(vertical = 38.dp, horizontal = 70.dp),
925             horizontalAlignment = Alignment.CenterHorizontally,
926         ) {
927             Icon(
928                 imageVector = Icons.Outlined.Widgets,
929                 contentDescription = stringResource(R.string.cta_label_to_open_widget_picker),
930                 modifier = Modifier.size(Dimensions.IconSize),
931             )
932             Spacer(modifier = Modifier.size(6.dp))
933             Text(
934                 text = stringResource(R.string.cta_label_to_edit_widget),
935                 style = MaterialTheme.typography.titleMedium,
936                 textAlign = TextAlign.Center,
937             )
938             Spacer(modifier = Modifier.size(20.dp))
939             Row(
940                 modifier = Modifier.fillMaxWidth(),
941                 horizontalArrangement = Arrangement.Center,
942             ) {
943                 OutlinedButton(
944                     colors =
945                         ButtonDefaults.buttonColors(
946                             contentColor = colors.onPrimary,
947                         ),
948                     border = BorderStroke(width = 1.0.dp, color = colors.primaryContainer),
949                     contentPadding = Dimensions.ButtonPadding,
950                     onClick = viewModel::onDismissCtaTile,
951                 ) {
952                     Text(
953                         text = stringResource(R.string.cta_tile_button_to_dismiss),
954                         fontSize = 12.sp,
955                     )
956                 }
957                 Spacer(modifier = Modifier.size(14.dp))
958                 Button(
959                     colors =
960                         ButtonDefaults.buttonColors(
961                             containerColor = colors.primaryContainer,
962                             contentColor = colors.onPrimaryContainer,
963                         ),
964                     contentPadding = Dimensions.ButtonPadding,
965                     onClick = viewModel::onOpenWidgetEditor
966                 ) {
967                     Text(
968                         text = stringResource(R.string.cta_tile_button_to_open_widget_editor),
969                         fontSize = 12.sp,
970                     )
971                 }
972             }
973         }
974     }
975 }
976 
977 @Composable
WidgetContentnull978 private fun WidgetContent(
979     viewModel: BaseCommunalViewModel,
980     model: CommunalContentModel.WidgetContent.Widget,
981     size: SizeF,
982     selected: Boolean,
983     widgetConfigurator: WidgetConfigurator?,
984     modifier: Modifier = Modifier,
985     index: Int,
986     contentListState: ContentListState,
987 ) {
988     val context = LocalContext.current
989     val isFocusable by viewModel.isFocusable.collectAsStateWithLifecycle(initialValue = false)
990     val accessibilityLabel =
991         remember(model, context) {
992             model.providerInfo.loadLabel(context.packageManager).toString().trim()
993         }
994     val clickActionLabel = stringResource(R.string.accessibility_action_label_select_widget)
995     val removeWidgetActionLabel = stringResource(R.string.accessibility_action_label_remove_widget)
996     val placeWidgetActionLabel = stringResource(R.string.accessibility_action_label_place_widget)
997     val selectedKey by viewModel.selectedKey.collectAsStateWithLifecycle()
998     val selectedIndex =
999         selectedKey?.let { key -> contentListState.list.indexOfFirst { it.key == key } }
1000     Box(
1001         modifier =
1002             modifier
1003                 .thenIf(!viewModel.isEditMode && model.inQuietMode) {
1004                     Modifier.pointerInput(Unit) {
1005                         // consume tap to prevent the child view from triggering interactions with
1006                         // the app widget
1007                         observeTaps(shouldConsume = true) { _ ->
1008                             viewModel.onOpenEnableWorkProfileDialog()
1009                         }
1010                     }
1011                 }
1012                 .thenIf(viewModel.isEditMode) {
1013                     Modifier.semantics {
1014                         contentDescription = accessibilityLabel
1015                         onClick(label = clickActionLabel, action = null)
1016                         val deleteAction =
1017                             CustomAccessibilityAction(removeWidgetActionLabel) {
1018                                 contentListState.onRemove(index)
1019                                 contentListState.onSaveList()
1020                                 true
1021                             }
1022                         val selectWidgetAction =
1023                             CustomAccessibilityAction(clickActionLabel) {
1024                                 val currentWidgetKey =
1025                                     index?.let {
1026                                         keyAtIndexIfEditable(contentListState.list, index)
1027                                     }
1028                                 viewModel.setSelectedKey(currentWidgetKey)
1029                                 true
1030                             }
1031 
1032                         val actions = mutableListOf(deleteAction, selectWidgetAction)
1033 
1034                         if (selectedIndex != null && selectedIndex != index) {
1035                             actions.add(
1036                                 CustomAccessibilityAction(placeWidgetActionLabel) {
1037                                     contentListState.onMove(selectedIndex!!, index)
1038                                     contentListState.onSaveList()
1039                                     viewModel.setSelectedKey(null)
1040                                     true
1041                                 }
1042                             )
1043                         }
1044 
1045                         customActions = actions
1046                     }
1047                 }
1048     ) {
1049         AndroidView(
1050             modifier = Modifier.fillMaxSize().allowGestures(allowed = !viewModel.isEditMode),
1051             factory = { context ->
1052                 model.appWidgetHost
1053                     .createViewForCommunal(context, model.appWidgetId, model.providerInfo)
1054                     .apply {
1055                         updateAppWidgetSize(
1056                             /* newOptions = */ Bundle(),
1057                             /* minWidth = */ size.width.toInt(),
1058                             /* minHeight = */ size.height.toInt(),
1059                             /* maxWidth = */ size.width.toInt(),
1060                             /* maxHeight = */ size.height.toInt(),
1061                             /* ignorePadding = */ true
1062                         )
1063                         accessibilityDelegate = viewModel.widgetAccessibilityDelegate
1064                     }
1065             },
1066             update = {
1067                 it.apply {
1068                     importantForAccessibility =
1069                         if (isFocusable) {
1070                             IMPORTANT_FOR_ACCESSIBILITY_AUTO
1071                         } else {
1072                             IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
1073                         }
1074                 }
1075             },
1076             // For reusing composition in lazy lists.
1077             onReset = {},
1078         )
1079         if (
1080             viewModel is CommunalEditModeViewModel &&
1081                 model.reconfigurable &&
1082                 widgetConfigurator != null
1083         ) {
1084             WidgetConfigureButton(
1085                 visible = selected,
1086                 model = model,
1087                 widgetConfigurator = widgetConfigurator,
1088                 modifier = Modifier.align(Alignment.BottomEnd)
1089             )
1090         }
1091     }
1092 }
1093 
1094 @Composable
WidgetConfigureButtonnull1095 fun WidgetConfigureButton(
1096     visible: Boolean,
1097     model: CommunalContentModel.WidgetContent.Widget,
1098     modifier: Modifier = Modifier,
1099     widgetConfigurator: WidgetConfigurator,
1100 ) {
1101     val colors = LocalAndroidColorScheme.current
1102     val scope = rememberCoroutineScope()
1103 
1104     AnimatedVisibility(
1105         visible = visible,
1106         enter = fadeIn(),
1107         exit = fadeOut(),
1108         modifier = modifier.padding(16.dp),
1109     ) {
1110         FilledIconButton(
1111             shape = RoundedCornerShape(16.dp),
1112             modifier = Modifier.size(48.dp),
1113             colors =
1114                 IconButtonColors(
1115                     containerColor = colors.primary,
1116                     contentColor = colors.onPrimary,
1117                     disabledContainerColor = Color.Transparent,
1118                     disabledContentColor = Color.Transparent
1119                 ),
1120             onClick = { scope.launch { widgetConfigurator.configureWidget(model.appWidgetId) } },
1121         ) {
1122             Icon(
1123                 imageVector = Icons.Outlined.Edit,
1124                 contentDescription = stringResource(id = R.string.edit_widget),
1125                 modifier = Modifier.padding(12.dp)
1126             )
1127         }
1128     }
1129 }
1130 
1131 @Composable
DisabledWidgetPlaceholdernull1132 fun DisabledWidgetPlaceholder(
1133     model: CommunalContentModel.WidgetContent.DisabledWidget,
1134     viewModel: BaseCommunalViewModel,
1135     modifier: Modifier = Modifier,
1136 ) {
1137     val context = LocalContext.current
1138     val appInfo = model.appInfo
1139     val icon: Icon =
1140         if (appInfo == null || appInfo.icon == 0) {
1141             Icon.createWithResource(context, android.R.drawable.sym_def_app_icon)
1142         } else {
1143             Icon.createWithResource(appInfo.packageName, appInfo.icon)
1144         }
1145 
1146     Column(
1147         modifier =
1148             modifier
1149                 .background(
1150                     MaterialTheme.colorScheme.surfaceVariant,
1151                     RoundedCornerShape(dimensionResource(system_app_widget_background_radius))
1152                 )
1153                 .clickable(
1154                     enabled = !viewModel.isEditMode,
1155                     interactionSource = null,
1156                     indication = null,
1157                     onClick = viewModel::onOpenEnableWidgetDialog
1158                 ),
1159         verticalArrangement = Arrangement.Center,
1160         horizontalAlignment = Alignment.CenterHorizontally,
1161     ) {
1162         Image(
1163             painter = rememberDrawablePainter(icon.loadDrawable(context)),
1164             contentDescription = stringResource(R.string.icon_description_for_disabled_widget),
1165             modifier = Modifier.size(Dimensions.IconSize),
1166             colorFilter = ColorFilter.colorMatrix(Colors.DisabledColorFilter),
1167         )
1168     }
1169 }
1170 
1171 @Composable
PendingWidgetPlaceholdernull1172 fun PendingWidgetPlaceholder(
1173     model: CommunalContentModel.WidgetContent.PendingWidget,
1174     modifier: Modifier = Modifier,
1175 ) {
1176     val context = LocalContext.current
1177     val icon: Icon =
1178         if (model.icon != null) {
1179             Icon.createWithBitmap(model.icon)
1180         } else {
1181             Icon.createWithResource(context, android.R.drawable.sym_def_app_icon)
1182         }
1183 
1184     Column(
1185         modifier =
1186             modifier.background(
1187                 MaterialTheme.colorScheme.surfaceVariant,
1188                 RoundedCornerShape(dimensionResource(system_app_widget_background_radius))
1189             ),
1190         verticalArrangement = Arrangement.Center,
1191         horizontalAlignment = Alignment.CenterHorizontally,
1192     ) {
1193         Image(
1194             painter = rememberDrawablePainter(icon.loadDrawable(context)),
1195             contentDescription = stringResource(R.string.icon_description_for_pending_widget),
1196             modifier = Modifier.size(Dimensions.IconSize),
1197         )
1198     }
1199 }
1200 
1201 @Composable
SmartspaceContentnull1202 private fun SmartspaceContent(
1203     interactionHandler: RemoteViews.InteractionHandler?,
1204     model: CommunalContentModel.Smartspace,
1205     modifier: Modifier = Modifier,
1206 ) {
1207     AndroidView(
1208         modifier = modifier,
1209         factory = { context ->
1210             SmartspaceAppWidgetHostView(context).apply {
1211                 interactionHandler?.let { setInteractionHandler(it) }
1212                 updateAppWidget(model.remoteViews)
1213             }
1214         },
1215         // For reusing composition in lazy lists.
1216         onReset = {},
1217     )
1218 }
1219 
1220 @Composable
TutorialContentnull1221 private fun TutorialContent(modifier: Modifier = Modifier) {
1222     Card(modifier = modifier, content = {})
1223 }
1224 
1225 @Composable
Umonull1226 private fun Umo(viewModel: BaseCommunalViewModel, modifier: Modifier = Modifier) {
1227     AndroidView(
1228         modifier = modifier,
1229         factory = {
1230             viewModel.mediaHost.hostView.layoutParams =
1231                 FrameLayout.LayoutParams(
1232                     FrameLayout.LayoutParams.MATCH_PARENT,
1233                     FrameLayout.LayoutParams.MATCH_PARENT
1234                 )
1235             viewModel.mediaHost.hostView
1236         },
1237         // For reusing composition in lazy lists.
1238         onReset = {},
1239     )
1240 }
1241 
1242 /** Container of the glanceable hub grid to enable accessibility actions when focused. */
1243 @Composable
AccessibilityContainernull1244 fun AccessibilityContainer(viewModel: BaseCommunalViewModel, content: @Composable () -> Unit) {
1245     val context = LocalContext.current
1246     val isFocusable by viewModel.isFocusable.collectAsStateWithLifecycle(initialValue = false)
1247     Box(
1248         modifier =
1249             Modifier.fillMaxWidth().wrapContentHeight().thenIf(
1250                 isFocusable && !viewModel.isEditMode
1251             ) {
1252                 Modifier.focusable(isFocusable).semantics {
1253                     contentDescription =
1254                         context.getString(
1255                             R.string.accessibility_content_description_for_communal_hub
1256                         )
1257                     customActions =
1258                         listOf(
1259                             CustomAccessibilityAction(
1260                                 context.getString(
1261                                     R.string.accessibility_action_label_close_communal_hub
1262                                 )
1263                             ) {
1264                                 viewModel.changeScene(CommunalScenes.Blank)
1265                                 true
1266                             },
1267                             CustomAccessibilityAction(
1268                                 context.getString(R.string.accessibility_action_label_edit_widgets)
1269                             ) {
1270                                 viewModel.onOpenWidgetEditor()
1271                                 true
1272                             }
1273                         )
1274                 }
1275             }
1276     ) {
1277         content()
1278     }
1279 }
1280 
1281 /**
1282  * Returns the `contentPadding` of the grid. Use the vertical padding to push the grid content area
1283  * below the toolbar and let the grid take the max size. This ensures the item can be dragged
1284  * outside the grid over the toolbar, without part of it getting clipped by the container.
1285  */
1286 @Composable
gridContentPaddingnull1287 private fun gridContentPadding(isEditMode: Boolean, toolbarSize: IntSize?): PaddingValues {
1288     if (!isEditMode || toolbarSize == null) {
1289         return PaddingValues(
1290             start = Dimensions.ItemSpacing,
1291             end = Dimensions.ItemSpacing,
1292             top = Dimensions.GridTopSpacing,
1293         )
1294     }
1295     val context = LocalContext.current
1296     val density = LocalDensity.current
1297     val windowMetrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context)
1298     val screenHeight = with(density) { windowMetrics.bounds.height().toDp() }
1299     val toolbarHeight = with(density) { Dimensions.ToolbarPaddingTop + toolbarSize.height.toDp() }
1300     val verticalPadding =
1301         ((screenHeight - toolbarHeight - Dimensions.GridHeight + Dimensions.GridTopSpacing) / 2)
1302             .coerceAtLeast(Dimensions.Spacing)
1303     return PaddingValues(
1304         start = Dimensions.ToolbarPaddingHorizontal,
1305         end = Dimensions.ToolbarPaddingHorizontal,
1306         top = verticalPadding + toolbarHeight,
1307         bottom = verticalPadding
1308     )
1309 }
1310 
1311 @Composable
beforeContentPaddingnull1312 private fun beforeContentPadding(paddingValues: PaddingValues): ContentPaddingInPx {
1313     return with(LocalDensity.current) {
1314         ContentPaddingInPx(
1315             start = paddingValues.calculateLeftPadding(LayoutDirection.Ltr).toPx(),
1316             top = paddingValues.calculateTopPadding().toPx()
1317         )
1318     }
1319 }
1320 
1321 /**
1322  * Check whether the pointer position that the item is being dragged at is within the coordinates of
1323  * the remove button in the toolbar. Returns true if the item is removable.
1324  */
1325 @VisibleForTesting
isPointerWithinEnabledRemoveButtonnull1326 fun isPointerWithinEnabledRemoveButton(
1327     removeEnabled: Boolean,
1328     offset: Offset?,
1329     containerToCheck: LayoutCoordinates?
1330 ): Boolean {
1331     if (!removeEnabled || offset == null || containerToCheck == null) {
1332         return false
1333     }
1334     val container = containerToCheck.boundsInWindow()
1335     return container.contains(offset)
1336 }
1337 
CommunalContentSizenull1338 private fun CommunalContentSize.dp(): Dp {
1339     return when (this) {
1340         CommunalContentSize.FULL -> Dimensions.CardHeightFull
1341         CommunalContentSize.HALF -> Dimensions.CardHeightHalf
1342         CommunalContentSize.THIRD -> Dimensions.CardHeightThird
1343     }
1344 }
1345 
firstIndexAtOffsetnull1346 private fun firstIndexAtOffset(gridState: LazyGridState, offset: Offset): Int? =
1347     gridState.layoutInfo.visibleItemsInfo.firstItemAtOffset(offset)?.index
1348 
1349 /** Returns the key of item if it's editable at the given index. Only widget is editable. */
1350 private fun keyAtIndexIfEditable(list: List<CommunalContentModel>, index: Int): String? =
1351     if (index in list.indices && list[index].isWidgetContent()) list[index].key else null
1352 
1353 data class ContentPaddingInPx(val start: Float, val top: Float) {
1354     fun toOffset(): Offset = Offset(start, top)
1355 }
1356 
1357 object Dimensions {
1358     val CardHeightFull = 530.dp
1359     val GridTopSpacing = 114.dp
1360     val GridHeight = CardHeightFull + GridTopSpacing
1361     val ItemSpacing = 50.dp
1362     val CardHeightHalf = (CardHeightFull - ItemSpacing) / 2
1363     val CardHeightThird = (CardHeightFull - (2 * ItemSpacing)) / 3
1364     val CardWidth = 360.dp
1365     val CardOutlineWidth = 3.dp
1366     val Spacing = ItemSpacing / 2
1367 
1368     // The sizing/padding of the toolbar in glanceable hub edit mode
1369     val ToolbarPaddingTop = 27.dp
1370     val ToolbarPaddingHorizontal = ItemSpacing
1371     val ToolbarButtonPaddingHorizontal = 24.dp
1372     val ToolbarButtonPaddingVertical = 16.dp
1373     val ButtonPadding =
1374         PaddingValues(
1375             vertical = ToolbarButtonPaddingVertical,
1376             horizontal = ToolbarButtonPaddingHorizontal,
1377         )
1378     val IconSize = 40.dp
1379     val SlideOffsetY = 30.dp
1380 }
1381 
1382 private object Colors {
<lambda>null1383     val DisabledColorFilter by lazy { disabledColorMatrix() }
1384 
1385     /** Returns the disabled image filter. Ported over from [DisableImageView]. */
disabledColorMatrixnull1386     private fun disabledColorMatrix(): ColorMatrix {
1387         val brightnessMatrix = ColorMatrix()
1388         val brightnessAmount = 0.5f
1389         val brightnessRgb = (255 * brightnessAmount).toInt().toFloat()
1390         // Brightness: C-new = C-old*(1-amount) + amount
1391         val scale = 1f - brightnessAmount
1392         val mat = brightnessMatrix.values
1393         mat[0] = scale
1394         mat[6] = scale
1395         mat[12] = scale
1396         mat[4] = brightnessRgb
1397         mat[9] = brightnessRgb
1398         mat[14] = brightnessRgb
1399 
1400         return ColorMatrix().apply {
1401             setToSaturation(0F)
1402             timesAssign(brightnessMatrix)
1403         }
1404     }
1405 }
1406 
1407 /** The resource id of communal hub accessible from UiAutomator. */
1408 private const val COMMUNAL_HUB_TEST_TAG = "communal_hub"
1409