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