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