Introduce Banners.

This commit is contained in:
Nicholas Tinsley
2024-08-06 21:50:44 +02:00
committed by mtang-signal
parent ef2c67d808
commit 11d165a17b
10 changed files with 449 additions and 2 deletions

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.banner
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import org.signal.core.util.logging.Log
/**
* This class represents a banner across the top of the screen.
*
* Typically, a class will subclass [Banner] and have a nested class that subclasses [BannerFactory].
* The constructor for an implementation of [Banner] should be very lightweight, as it is may be called frequently.
*/
abstract class Banner {
companion object {
private val TAG = Log.tag(Banner::class)
/**
* A helper function to create a [Flow] of a [Banner].
*
* @param bannerFactory a block the produces a [Banner], or null. Returning null will complete the [Flow] without emitting any values.
*/
@JvmStatic
fun <T : Banner> createAndEmit(bannerFactory: () -> T?): Flow<T> {
return bannerFactory()?.let {
flow { emit(it) }
} ?: emptyFlow()
}
}
/**
* Whether or not the [Banner] should be shown (enabled) or hidden (disabled).
*/
abstract var enabled: Boolean
/**
* Composable function to display content when [enabled] is true.
*
* @see [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner]
*/
@Composable
abstract fun DisplayBanner()
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import org.signal.core.util.logging.Log
/**
* A class that can be instantiated with a list of [Flow]s that produce [Banner]s, then applied to a [ComposeView], typically within a [Fragment].
* Usually, the [Flow]s will come from [Banner.BannerFactory] instances, but may also be produced by the other properties of the host.
*/
class BannerManager(allFlows: Iterable<Flow<Banner>>) {
companion object {
val TAG = Log.tag(BannerManager::class)
}
/**
* Takes the flows and combines them into one so that a new [Flow] value from any of them will trigger an update to the UI.
*
* **NOTE**: This will **not** emit its first value until **all** of the input flows have each emitted *at least one value*.
*/
private val combinedFlow: Flow<List<Banner>> = combine(allFlows) { banners: Array<Banner> ->
banners.filter { it.enabled }.toList()
}
/**
* Sets the content of the provided [ComposeView] to one that consumes the lists emitted by [combinedFlow] and displays them.
*/
fun setContent(composeView: ComposeView) {
composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val state = combinedFlow.collectAsStateWithLifecycle(initialValue = emptyList())
state.value.firstOrNull()?.let {
Box(modifier = Modifier.padding(8.dp)) {
it.DisplayBanner()
}
}
}
}
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.PlayStoreUtil
/**
* Banner to let the user know their build is about to expire.
*
* This serves as an example for how we can replicate the functionality of the old [org.thoughtcrime.securesms.components.reminder.Reminder] system purely in the new [Banner] system.
*/
class ExpiredBuildBanner(val context: Context) : Banner() {
override var enabled = true
@Composable
override fun DisplayBanner() {
DefaultBanner(
title = null,
body = stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today),
importance = Importance.TERMINAL,
isDismissible = false,
actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
}
),
onHideListener = {},
onDismissListener = {}
)
}
companion object {
@JvmStatic
fun createFlow(context: Context): Flow<Banner> = createAndEmit {
if (SignalStore.misc.isClientDeprecated) {
ExpiredBuildBanner(context)
} else {
null
}
}
}
}

View File

@@ -0,0 +1,237 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.banner.ui.compose
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.SignalPreview
import org.signal.core.util.isNotNullOrBlank
import org.thoughtcrime.securesms.R
/**
* A layout intended to display an in-app notification at the top of their screen,
* and optionally allow them to take some action(s) in response.
*/
@Composable
fun DefaultBanner(
title: String?,
body: String,
importance: Importance,
isDismissible: Boolean,
onDismissListener: () -> Unit,
onHideListener: () -> Unit,
@DrawableRes icon: Int? = null,
actions: List<Action> = emptyList(),
showProgress: Boolean = false,
progressText: String = "",
progressPercent: Int = -1
) {
Box(
modifier = Modifier
.background(
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.surface
Importance.ERROR, Importance.TERMINAL -> colorResource(id = R.color.reminder_background)
}
)
.border(
width = 1.dp,
color = colorResource(id = R.color.signal_colorOutline_38),
shape = RoundedCornerShape(12.dp)
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.defaultMinSize(minHeight = 74.dp)
) {
if (icon != null) {
Box(
modifier = Modifier
.padding(horizontal = 12.dp)
.size(48.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = icon),
contentDescription = stringResource(id = R.string.ReminderView_icon_content_description),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.5.dp)
)
}
}
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.padding(12.dp)
.weight(1f)
) {
if (title.isNotNullOrBlank()) {
Text(
text = title,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurface
Importance.ERROR, Importance.TERMINAL -> colorResource(id = R.color.signal_light_colorOnSurface)
},
style = MaterialTheme.typography.bodyLarge
)
}
Text(
text = body,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
Importance.ERROR, Importance.TERMINAL -> colorResource(id = R.color.signal_light_colorOnSurface)
},
style = MaterialTheme.typography.bodyMedium
)
if (showProgress) {
if (progressPercent >= 0) {
LinearProgressIndicator(
progress = { progressPercent / 100f },
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(vertical = 12.dp)
)
} else {
LinearProgressIndicator(
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(vertical = 12.dp)
)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodySmall,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
Importance.ERROR, Importance.TERMINAL -> colorResource(id = R.color.signal_light_colorOnSurface)
}
)
}
}
if (isDismissible) {
IconButton(
onClick = {
onHideListener()
onDismissListener()
},
modifier = Modifier.size(48.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_x_24),
contentDescription = stringResource(id = R.string.InviteActivity_cancel)
)
}
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
for (action in actions) {
TextButton(onClick = action.onClick) {
Text(text = stringResource(id = action.label))
}
}
}
}
}
}
}
data class Action(@StringRes val label: Int, val onClick: () -> Unit)
enum class Importance {
NORMAL, ERROR, TERMINAL
}
@Composable
@SignalPreview
private fun ForcedUpgradePreview() {
DefaultBanner(
title = null,
body = stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today),
importance = Importance.TERMINAL,
isDismissible = false,
actions = listOf(Action(R.string.ExpiredBuildReminder_update_now) {}),
onHideListener = { },
onDismissListener = {}
)
}
@Composable
@SignalPreview
private fun FullyLoadedErrorPreview() {
val actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) { },
Action(R.string.BubbleOptOutTooltip__turn_off) { }
)
DefaultBanner(
icon = R.drawable.symbol_error_circle_24,
title = "Error",
body = "Creating more errors.",
importance = Importance.ERROR,
isDismissible = true,
actions = actions,
showProgress = true,
progressText = "4 out of 10 errors created.",
progressPercent = 40,
onHideListener = { },
onDismissListener = {}
)
}
@Composable
@SignalPreview
private fun FullyLoadedTerminalPreview() {
val actions = listOf(
Action(R.string.ExpiredBuildReminder_update_now) { },
Action(R.string.BubbleOptOutTooltip__turn_off) { }
)
DefaultBanner(
icon = R.drawable.symbol_error_circle_24,
title = "Terminal",
body = "This is a terminal state.",
importance = Importance.TERMINAL,
isDismissible = true,
actions = actions,
showProgress = true,
progressText = "93% terminated",
progressPercent = 93,
onHideListener = { },
onDismissListener = {}
)
}