blob: 2a9cf0fdc5075532539c709ec897ba50eaa81bfd [file] [log] [blame]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.communal.ui.compose
import android.os.Bundle
import android.util.SizeF
import android.widget.FrameLayout
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.systemui.communal.domain.model.CommunalContentModel
import com.android.systemui.communal.shared.model.CommunalContentSize
import com.android.systemui.communal.ui.viewmodel.BaseCommunalViewModel
import com.android.systemui.communal.ui.viewmodel.CommunalEditModeViewModel
import com.android.systemui.media.controls.ui.MediaHierarchyManager
import com.android.systemui.media.controls.ui.MediaHostState
import com.android.systemui.res.R
@Composable
fun CommunalHub(
modifier: Modifier = Modifier,
viewModel: BaseCommunalViewModel,
onOpenWidgetPicker: (() -> Unit)? = null,
onEditDone: (() -> Unit)? = null,
) {
val communalContent by viewModel.communalContent.collectAsState(initial = emptyList())
var removeButtonCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
var toolbarSize: IntSize? by remember { mutableStateOf(null) }
var gridCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
var isDraggingToRemove by remember { mutableStateOf(false) }
Box(
modifier = modifier.fillMaxSize().background(Color.White),
) {
CommunalHubLazyGrid(
modifier = Modifier.align(Alignment.CenterStart),
communalContent = communalContent,
viewModel = viewModel,
contentPadding = gridContentPadding(viewModel.isEditMode, toolbarSize),
setGridCoordinates = { gridCoordinates = it },
updateDragPositionForRemove = {
isDraggingToRemove =
checkForDraggingToRemove(it, removeButtonCoordinates, gridCoordinates)
isDraggingToRemove
}
)
if (viewModel.isEditMode && onOpenWidgetPicker != null && onEditDone != null) {
Toolbar(
isDraggingToRemove = isDraggingToRemove,
setToolbarSize = { toolbarSize = it },
setRemoveButtonCoordinates = { removeButtonCoordinates = it },
onEditDone = onEditDone,
onOpenWidgetPicker = onOpenWidgetPicker,
)
} else {
IconButton(onClick = viewModel::onOpenWidgetEditor) {
Icon(Icons.Default.Edit, stringResource(R.string.button_to_open_widget_editor))
}
}
// This spacer covers the edge of the LazyHorizontalGrid and prevents it from receiving
// touches, so that the SceneTransitionLayout can intercept the touches and allow an edge
// swipe back to the blank scene.
Spacer(
Modifier.height(Dimensions.GridHeight)
.align(Alignment.CenterStart)
.width(Dimensions.Spacing)
.pointerInput(Unit) {}
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CommunalHubLazyGrid(
communalContent: List<CommunalContentModel>,
viewModel: BaseCommunalViewModel,
modifier: Modifier = Modifier,
contentPadding: PaddingValues,
setGridCoordinates: (coordinates: LayoutCoordinates) -> Unit,
updateDragPositionForRemove: (offset: Offset) -> Boolean,
) {
var gridModifier = modifier
val gridState = rememberLazyGridState()
var list = communalContent
var dragDropState: GridDragDropState? = null
if (viewModel.isEditMode && viewModel is CommunalEditModeViewModel) {
val contentListState = rememberContentListState(communalContent, viewModel)
list = contentListState.list
dragDropState =
rememberGridDragDropState(
gridState = gridState,
contentListState = contentListState,
updateDragPositionForRemove = updateDragPositionForRemove
)
gridModifier =
gridModifier
.fillMaxSize()
.dragContainer(dragDropState, beforeContentPadding(contentPadding))
.onGloballyPositioned { setGridCoordinates(it) }
} else {
gridModifier = gridModifier.height(Dimensions.GridHeight)
}
LazyHorizontalGrid(
modifier = gridModifier,
state = gridState,
rows = GridCells.Fixed(CommunalContentSize.FULL.span),
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(Dimensions.Spacing),
verticalArrangement = Arrangement.spacedBy(Dimensions.Spacing),
) {
items(
count = list.size,
key = { index -> list[index].key },
span = { index -> GridItemSpan(list[index].size.span) },
) { index ->
val cardModifier = Modifier.width(Dimensions.CardWidth)
val size =
SizeF(
Dimensions.CardWidth.value,
list[index].size.dp().value,
)
if (viewModel.isEditMode && dragDropState != null) {
DraggableItem(dragDropState = dragDropState, enabled = true, index = index) {
isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 1.dp)
CommunalContent(
modifier = cardModifier,
elevation = elevation,
model = list[index],
viewModel = viewModel,
size = size,
)
}
} else {
CommunalContent(
modifier = cardModifier,
model = list[index],
viewModel = viewModel,
size = size,
)
}
}
}
}
/**
* Toolbar that contains action buttons to
* 1) open the widget picker
* 2) remove a widget from the grid and
* 3) exit the edit mode.
*/
@Composable
private fun Toolbar(
isDraggingToRemove: Boolean,
setToolbarSize: (toolbarSize: IntSize) -> Unit,
setRemoveButtonCoordinates: (coordinates: LayoutCoordinates) -> Unit,
onOpenWidgetPicker: () -> Unit,
onEditDone: () -> Unit,
) {
Row(
modifier =
Modifier.fillMaxWidth()
.padding(
top = Dimensions.ToolbarPaddingTop,
start = Dimensions.ToolbarPaddingHorizontal,
end = Dimensions.ToolbarPaddingHorizontal,
)
.onSizeChanged { setToolbarSize(it) },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val buttonContentPadding =
PaddingValues(
vertical = Dimensions.ToolbarButtonPaddingVertical,
horizontal = Dimensions.ToolbarButtonPaddingHorizontal,
)
val spacerModifier = Modifier.width(Dimensions.ToolbarButtonSpaceBetween)
Button(
onClick = onOpenWidgetPicker,
colors = filledSecondaryButtonColors(),
contentPadding = buttonContentPadding
) {
Icon(Icons.Default.Add, stringResource(R.string.button_to_open_widget_editor))
Spacer(spacerModifier)
Text(
text = stringResource(R.string.hub_mode_add_widget_button_text),
)
}
val buttonColors =
if (isDraggingToRemove) filledButtonColors() else ButtonDefaults.outlinedButtonColors()
OutlinedButton(
onClick = {},
colors = buttonColors,
contentPadding = buttonContentPadding,
modifier = Modifier.onGloballyPositioned { setRemoveButtonCoordinates(it) },
) {
Icon(Icons.Outlined.Delete, stringResource(R.string.button_to_open_widget_editor))
Spacer(spacerModifier)
Text(
text = stringResource(R.string.button_to_remove_widget),
)
}
Button(
onClick = onEditDone,
colors = filledButtonColors(),
contentPadding = buttonContentPadding
) {
Text(
text = stringResource(R.string.hub_mode_editing_exit_button_text),
)
}
}
}
@Composable
private fun filledButtonColors(): ButtonColors {
val colors = LocalAndroidColorScheme.current
return ButtonDefaults.buttonColors(
containerColor = colors.primary,
contentColor = colors.onPrimary,
)
}
@Composable
private fun filledSecondaryButtonColors(): ButtonColors {
val colors = LocalAndroidColorScheme.current
return ButtonDefaults.buttonColors(
containerColor = colors.secondary,
contentColor = colors.onSecondary,
)
}
@Composable
private fun CommunalContent(
model: CommunalContentModel,
viewModel: BaseCommunalViewModel,
size: SizeF,
modifier: Modifier = Modifier,
elevation: Dp = 0.dp,
) {
when (model) {
is CommunalContentModel.Widget -> WidgetContent(model, size, elevation, modifier)
is CommunalContentModel.Smartspace -> SmartspaceContent(model, modifier)
is CommunalContentModel.Tutorial -> TutorialContent(modifier)
is CommunalContentModel.Umo -> Umo(viewModel, modifier)
}
}
@Composable
private fun WidgetContent(
model: CommunalContentModel.Widget,
size: SizeF,
elevation: Dp,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.height(size.height.dp),
elevation = CardDefaults.cardElevation(draggedElevation = elevation),
) {
AndroidView(
modifier = modifier,
factory = { context ->
model.appWidgetHost
.createView(context, model.appWidgetId, model.providerInfo)
.apply { updateAppWidgetSize(Bundle.EMPTY, listOf(size)) }
},
)
}
}
@Composable
private fun SmartspaceContent(
model: CommunalContentModel.Smartspace,
modifier: Modifier = Modifier,
) {
AndroidView(
modifier = modifier,
factory = { context ->
FrameLayout(context).apply { addView(model.remoteViews.apply(context, this)) }
},
// For reusing composition in lazy lists.
onReset = {}
)
}
@Composable
private fun TutorialContent(modifier: Modifier = Modifier) {
Card(modifier = modifier, content = {})
}
@Composable
private fun Umo(viewModel: BaseCommunalViewModel, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier,
factory = {
viewModel.mediaHost.expansion = MediaHostState.EXPANDED
viewModel.mediaHost.showsOnlyActiveMedia = false
viewModel.mediaHost.falsingProtectionNeeded = false
viewModel.mediaHost.init(MediaHierarchyManager.LOCATION_COMMUNAL_HUB)
viewModel.mediaHost.hostView.layoutParams =
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
viewModel.mediaHost.hostView
},
// For reusing composition in lazy lists.
onReset = {},
)
}
/**
* Returns the `contentPadding` of the grid. Use the vertical padding to push the grid content area
* below the toolbar and let the grid take the max size. This ensures the item can be dragged
* outside the grid over the toolbar, without part of it getting clipped by the container.
*/
@Composable
private fun gridContentPadding(isEditMode: Boolean, toolbarSize: IntSize?): PaddingValues {
if (!isEditMode || toolbarSize == null) {
return PaddingValues(horizontal = Dimensions.Spacing)
}
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val screenHeight = configuration.screenHeightDp.dp
val toolbarHeight = with(density) { Dimensions.ToolbarPaddingTop + toolbarSize.height.toDp() }
val verticalPadding =
((screenHeight - toolbarHeight - Dimensions.GridHeight) / 2).coerceAtLeast(
Dimensions.Spacing
)
return PaddingValues(
start = Dimensions.ToolbarPaddingHorizontal,
end = Dimensions.ToolbarPaddingHorizontal,
top = verticalPadding + toolbarHeight,
bottom = verticalPadding
)
}
@Composable
private fun beforeContentPadding(paddingValues: PaddingValues): ContentPaddingInPx {
return with(LocalDensity.current) {
ContentPaddingInPx(
startPadding = paddingValues.calculateLeftPadding(LayoutDirection.Ltr).toPx(),
topPadding = paddingValues.calculateTopPadding().toPx()
)
}
}
/**
* Check whether the pointer position that the item is being dragged at is within the coordinates of
* the remove button in the toolbar. Returns true if the item is removable.
*/
private fun checkForDraggingToRemove(
offset: Offset,
removeButtonCoordinates: LayoutCoordinates?,
gridCoordinates: LayoutCoordinates?,
): Boolean {
if (removeButtonCoordinates == null || gridCoordinates == null) {
return false
}
val pointer = gridCoordinates.positionInWindow() + offset
val removeButton = removeButtonCoordinates.positionInWindow()
return pointer.x in removeButton.x..removeButton.x + removeButtonCoordinates.size.width &&
pointer.y in removeButton.y..removeButton.y + removeButtonCoordinates.size.height
}
private fun CommunalContentSize.dp(): Dp {
return when (this) {
CommunalContentSize.FULL -> Dimensions.CardHeightFull
CommunalContentSize.HALF -> Dimensions.CardHeightHalf
CommunalContentSize.THIRD -> Dimensions.CardHeightThird
}
}
data class ContentPaddingInPx(val startPadding: Float, val topPadding: Float)
object Dimensions {
val CardWidth = 464.dp
val CardHeightFull = 630.dp
val CardHeightHalf = 307.dp
val CardHeightThird = 199.dp
val GridHeight = CardHeightFull
val Spacing = 16.dp
// The sizing/padding of the toolbar in glanceable hub edit mode
val ToolbarPaddingTop = 27.dp
val ToolbarPaddingHorizontal = 16.dp
val ToolbarButtonPaddingHorizontal = 24.dp
val ToolbarButtonPaddingVertical = 16.dp
val ToolbarButtonSpaceBetween = 8.dp
}