mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Introduce Banners.
This commit is contained in:
committed by
mtang-signal
parent
ef2c67d808
commit
11d165a17b
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user