You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iceraven-browser/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabs.kt

354 lines
12 KiB
Kotlin

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@file:Suppress("TooManyFunctions")
package org.mozilla.fenix.tabstray.syncedtabs
import android.content.res.Configuration
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
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.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import mozilla.components.browser.storage.sync.SyncedDeviceTabs
import mozilla.components.browser.storage.sync.TabEntry
import mozilla.components.browser.toolbar.MAX_URI_LENGTH
import mozilla.components.feature.syncedtabs.view.SyncedTabsView
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.PrimaryText
import org.mozilla.fenix.compose.SecondaryText
import org.mozilla.fenix.theme.FirefoxTheme
import mozilla.components.browser.storage.sync.Tab as SyncTab
/**
* Top-level list UI for displaying Synced Tabs in the Tabs Tray.
*
* @param syncedTabs The tab UI items to be displayed.
* @param onTabClick The lambda for handling clicks on synced tabs.
*/
@Composable
fun SyncedTabsList(syncedTabs: List<SyncedTabsListItem>, onTabClick: (SyncTab) -> Unit) {
val listState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
) {
items(syncedTabs) { syncedTabItem ->
when (syncedTabItem) {
is SyncedTabsListItem.Device -> SyncedTabsDeviceItem(deviceName = syncedTabItem.displayName)
is SyncedTabsListItem.Error -> SyncedTabsErrorItem(
errorText = syncedTabItem.errorText,
errorButton = syncedTabItem.errorButton
)
is SyncedTabsListItem.NoTabs -> SyncedTabsNoTabsItem()
is SyncedTabsListItem.Tab -> {
SyncedTabsTabItem(
tabTitleText = syncedTabItem.displayTitle,
url = syncedTabItem.displayURL,
) {
onTabClick(syncedTabItem.tab)
}
}
}
}
item {
// The Spacer here is to act as a footer to add padding to the bottom of the list so
// the FAB or any potential SnackBar doesn't overlap with the items at the end.
Spacer(Modifier.height(240.dp))
}
}
}
/**
* Text header for sections of synced tabs
*
* @param deviceName The name of the user's device connected that has synced tabs.
*/
@Composable
fun SyncedTabsDeviceItem(deviceName: String) {
Column(Modifier.fillMaxWidth()) {
PrimaryText(
text = deviceName,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 16.dp, end = 8.dp, bottom = 8.dp),
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.metropolis_semibold)),
maxLines = 1
)
Divider(color = FirefoxTheme.colors.borderPrimary)
}
}
/**
* Synced tab list item UI
*
* @param tabTitleText The tab's display text.
* @param url The tab's URL.
* @param onClick The click handler when this synced tab is clicked.
*/
@Composable
fun SyncedTabsTabItem(tabTitleText: String, url: String, onClick: () -> Unit) {
Column(
modifier = Modifier
.clickable(
onClickLabel = tabTitleText,
onClick = onClick
)
.padding(horizontal = 16.dp)
.defaultMinSize(minHeight = 56.dp),
verticalArrangement = Arrangement.Center,
) {
PrimaryText(
text = tabTitleText,
modifier = Modifier.fillMaxWidth(),
fontSize = 16.sp,
maxLines = 1
)
SecondaryText(
text = url,
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
fontSize = 12.sp,
maxLines = 1
)
}
}
/**
* Error UI to show if there is one of the errors outlined in [SyncedTabsView.ErrorType].
*
* @param errorText The text to be displayed to the user.
* @param errorButton Optional class to set up and handle any clicks in the Error UI.
*/
@Composable
fun SyncedTabsErrorItem(errorText: String, errorButton: SyncedTabsListItem.ErrorButton? = null) {
Box(
Modifier
.padding(all = 16.dp)
.height(IntrinsicSize.Min)
) {
val dashColor = FirefoxTheme.colors.borderPrimary
Canvas(Modifier.fillMaxSize()) {
drawRoundRect(
color = dashColor,
style = Stroke(
width = 2.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 4.dp.toPx()), 0f)
),
cornerRadius = CornerRadius(
x = 8.dp.toPx(),
y = 8.dp.toPx()
),
)
}
Column(
Modifier
.padding(all = 16.dp)
.fillMaxWidth()
) {
PrimaryText(
text = errorText,
modifier = Modifier.fillMaxWidth(),
fontSize = 14.sp
)
errorButton?.let {
Spacer(modifier = Modifier.height(12.dp))
SyncedTabsErrorButton(buttonText = it.buttonText, onClick = it.onClick)
}
}
}
}
/**
* Error button UI within SyncedTabsErrorItem
*
* @param buttonText The error button's text and accessibility hint.
* @param onClick The lambda called when the button is clicked.
*/
@Composable
fun SyncedTabsErrorButton(buttonText: String, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.clip(RoundedCornerShape(size = 4.dp)),
elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp),
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = FirefoxTheme.colors.actionPrimary),
) {
Icon(
painter = painterResource(R.drawable.ic_sign_in),
contentDescription = null,
tint = FirefoxTheme.colors.textOnColorPrimary,
)
Spacer(Modifier.width(8.dp))
Text(
text = buttonText,
modifier = Modifier.align(Alignment.CenterVertically),
color = FirefoxTheme.colors.textOnColorPrimary,
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.metropolis_semibold)),
maxLines = 2
)
}
}
/**
* UI to be displayed when a user's device has no synced tabs.
*/
@Composable
fun SyncedTabsNoTabsItem() {
SecondaryText(
text = stringResource(R.string.synced_tabs_no_open_tabs),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
fontSize = 16.sp,
maxLines = 1
)
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SyncedTabsListItemsPreview() {
FirefoxTheme {
Column(Modifier.background(FirefoxTheme.colors.layer1)) {
SyncedTabsDeviceItem(deviceName = "Google Pixel Pro Max +Ultra 5000")
Spacer(Modifier.height(16.dp))
SyncedTabsTabItem(tabTitleText = "Mozilla", url = "www.mozilla.org") { println("Clicked tab") }
Spacer(Modifier.height(16.dp))
SyncedTabsErrorItem(errorText = stringResource(R.string.synced_tabs_reauth))
Spacer(Modifier.height(16.dp))
SyncedTabsNoTabsItem()
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SyncedTabsErrorPreview() {
FirefoxTheme {
Box(Modifier.background(FirefoxTheme.colors.layer1)) {
SyncedTabsErrorItem(
errorText = stringResource(R.string.synced_tabs_no_tabs),
errorButton = SyncedTabsListItem.ErrorButton(
buttonText = stringResource(R.string.synced_tabs_sign_in_button)
) {
println("SyncedTabsErrorButton click")
},
)
}
}
}
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SyncedTabsListPreview() {
FirefoxTheme {
Box(Modifier.background(FirefoxTheme.colors.layer1)) {
SyncedTabsList(
getFakeSyncedTabList(),
) {
println("Tab clicked")
}
}
}
}
/**
* Converts a list of [SyncedDeviceTabs] into a list of [SyncedTabsListItem].
*/
fun List<SyncedDeviceTabs>.toComposeList() = asSequence().flatMap { (device, tabs) ->
// Transform to sticky headers data here https://github.com/mozilla-mobile/fenix/issues/19942
val deviceTabs = if (tabs.isEmpty()) {
sequenceOf(SyncedTabsListItem.NoTabs)
} else {
tabs.asSequence().map {
val url = it.active().url
val titleText = it.active().title.ifEmpty { url.take(MAX_URI_LENGTH) }
SyncedTabsListItem.Tab(titleText, url, it)
}
}
sequenceOf(SyncedTabsListItem.Device(device.displayName)) + deviceTabs
}.toList()
/**
* Helper function to create a List of [SyncedTabsListItem] for previewing.
*/
@VisibleForTesting internal fun getFakeSyncedTabList(): List<SyncedTabsListItem> = listOf(
SyncedTabsListItem.Device("Device 1"),
generateFakeTab("Mozilla", "www.mozilla.org"),
generateFakeTab("Google", "www.google.com"),
generateFakeTab("", "www.google.com"),
SyncedTabsListItem.Device("Device 2"),
SyncedTabsListItem.NoTabs,
SyncedTabsListItem.Device("Device 3"),
SyncedTabsListItem.Error("Please re-authenticate"),
)
/**
* Helper function to create a [SyncedTabsListItem.Tab] for previewing.
*/
private fun generateFakeTab(tabName: String, tabUrl: String): SyncedTabsListItem.Tab = SyncedTabsListItem.Tab(
tabName.ifEmpty { tabUrl },
tabUrl,
SyncTab(
history = listOf(TabEntry(tabName, tabUrl, null)),
active = 0,
lastUsed = 0L,
)
)