mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 18:30:20 +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 = {}
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ProgressBar;
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.view.ActionMode;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.compose.ui.platform.ComposeView;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewCompat;
|
||||
@@ -94,6 +95,9 @@ import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertDelegate;
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.banner.Banner;
|
||||
import org.thoughtcrime.securesms.banner.BannerManager;
|
||||
import org.thoughtcrime.securesms.banner.banners.ExpiredBuildBanner;
|
||||
import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog;
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar;
|
||||
import org.thoughtcrime.securesms.components.RatingManager;
|
||||
@@ -171,6 +175,7 @@ import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil;
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalProxyUtil;
|
||||
@@ -198,6 +203,7 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import kotlin.Unit;
|
||||
import kotlinx.coroutines.flow.Flow;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
|
||||
@@ -223,6 +229,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private View coordinator;
|
||||
private RecyclerView list;
|
||||
private Stub<ReminderView> reminderView;
|
||||
private Stub<ComposeView> bannerView;
|
||||
private PulsingFloatingActionButton fab;
|
||||
private PulsingFloatingActionButton cameraFab;
|
||||
private ConversationListFilterPullView pullView;
|
||||
@@ -287,6 +294,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
list = view.findViewById(R.id.list);
|
||||
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
|
||||
reminderView = new Stub<>(view.findViewById(R.id.reminder));
|
||||
bannerView = new Stub<>(view.findViewById(R.id.banner_compose_view));
|
||||
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
|
||||
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
|
||||
fab = view.findViewById(R.id.fab);
|
||||
@@ -414,6 +422,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
initializeListAdapters();
|
||||
initializeTypingObserver();
|
||||
initializeVoiceNotePlayer();
|
||||
initializeBanners();
|
||||
|
||||
RatingManager.showRatingDialogIfNecessary(requireContext());
|
||||
|
||||
@@ -871,6 +880,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeBanners() {
|
||||
if (RemoteConfig.newBannerUi()) {
|
||||
final List<Flow<Banner>> bannerRepositories = List.of(ExpiredBuildBanner.createFlow(requireContext()));
|
||||
final BannerManager bannerManager = new BannerManager(bannerRepositories);
|
||||
bannerManager.setContent(bannerView.get());
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull VoiceNotePlayerView requireVoiceNotePlayerView() {
|
||||
if (voiceNotePlayerView == null) {
|
||||
voiceNotePlayerView = voiceNotePlayerViewStub.get().findViewById(R.id.voice_note_player_view);
|
||||
@@ -1042,6 +1059,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
|
||||
private void updateReminders() {
|
||||
if (RemoteConfig.newBannerUi()) {
|
||||
return;
|
||||
}
|
||||
Context context = requireContext();
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
|
||||
@@ -1091,6 +1091,15 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** Whether to use the new Banner system instead of the old Reminder system. */
|
||||
@JvmStatic
|
||||
@get:JvmName("newBannerUi")
|
||||
val newBannerUi: Boolean by remoteBoolean(
|
||||
key = "android.newBannerUi",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** Which phase we're in for the SVR3 migration */
|
||||
val svr3MigrationPhase: Int by remoteInt(
|
||||
key = "global.svr3.phase",
|
||||
|
||||
Reference in New Issue
Block a user