Add scaffolding for backupsV2.

This commit is contained in:
Alex Hart
2024-01-31 16:27:18 -04:00
committed by Nicholas Tinsley
parent 91920319c7
commit 1234c63836
27 changed files with 1551 additions and 20 deletions

View File

@@ -0,0 +1,261 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updateLayoutParams
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsCheckoutSheet(
messageBackupsType: MessageBackupsType,
availablePaymentGateways: List<GatewayResponse.Gateway>,
onDismissRequest: () -> Unit,
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
dragHandle = { BottomSheets.Handle() },
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
SheetContent(
messageBackupsType = messageBackupsType,
availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = onPaymentGatewaySelected
)
}
}
@Composable
private fun SheetContent(
messageBackupsType: MessageBackupsType,
availablePaymentGateways: List<GatewayResponse.Gateway>,
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
) {
val resources = LocalContext.current.resources
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Text(
text = "Pay $formattedPrice/month to Signal", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 48.dp)
)
Text(
text = "You'll get:", // TODO [message-backups] Finalized copy
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 5.dp)
)
MessageBackupsTypeBlock(
messageBackupsType = messageBackupsType,
isSelected = false,
onSelected = {},
enabled = false,
modifier = Modifier.padding(top = 24.dp)
)
Column(
verticalArrangement = spacedBy(12.dp),
modifier = Modifier.padding(top = 48.dp, bottom = 24.dp)
) {
availablePaymentGateways.forEach {
when (it) {
GatewayResponse.Gateway.GOOGLE_PAY -> GooglePayButton {
onPaymentGatewaySelected(GatewayResponse.Gateway.GOOGLE_PAY)
}
GatewayResponse.Gateway.PAYPAL -> PayPalButton {
onPaymentGatewaySelected(GatewayResponse.Gateway.PAYPAL)
}
GatewayResponse.Gateway.CREDIT_CARD -> CreditOrDebitCardButton {
onPaymentGatewaySelected(GatewayResponse.Gateway.CREDIT_CARD)
}
GatewayResponse.Gateway.SEPA_DEBIT -> SepaButton {
onPaymentGatewaySelected(GatewayResponse.Gateway.SEPA_DEBIT)
}
GatewayResponse.Gateway.IDEAL -> IdealButton {
onPaymentGatewaySelected(GatewayResponse.Gateway.IDEAL)
}
}
}
}
}
@Composable
private fun PayPalButton(
onClick: () -> Unit
) {
AndroidView(factory = {
val view = LayoutInflater.from(it).inflate(R.layout.paypal_button, null)
view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
view
}) {
val binding = PaypalButtonBinding.bind(it)
binding.paypalButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
marginStart = 0
marginEnd = 0
}
binding.paypalButton.setOnClickListener {
onClick()
}
}
}
@Composable
private fun GooglePayButton(
onClick: () -> Unit
) {
val model = GooglePayButton.Model(onClick, true)
AndroidView(factory = {
LayoutInflater.from(it).inflate(R.layout.google_pay_button_pref, null)
}) {
val holder = GooglePayButton.ViewHolder(it)
holder.bind(model)
}
}
@Composable
private fun SepaButton(
onClick: () -> Unit
) {
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.bank_transfer),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__bank_transfer))
}
}
@Composable
private fun IdealButton(
onClick: () -> Unit
) {
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.drawable.logo_ideal),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.padding(end = 8.dp)
)
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal))
}
}
@Composable
private fun CreditOrDebitCardButton(
onClick: () -> Unit
) {
Buttons.LargePrimary(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.credit_card),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = stringResource(id = R.string.GatewaySelectorBottomSheet__credit_or_debit_card)
)
}
}
@Preview
@Composable
private fun MessageBackupsCheckoutSheetPreview() {
val paidTier = MessageBackupsType(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
title = "Text + All your media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Full media backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "1TB of storage (~250K photos)"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
label = "Thanks for supporting Signal!"
)
)
)
val availablePaymentGateways = GatewayResponse.Gateway.values().toList()
Previews.Preview {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
SheetContent(
messageBackupsType = paidTier,
availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = {}
)
}
}
}

View File

@@ -0,0 +1,187 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
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.graphics.painter.Painter
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
/**
* Educational content which allows user to proceed to set up automatic backups
* or navigate to a support page to learn more.
*/
@Composable
fun MessageBackupsEducationScreen(
onNavigationClick: () -> Unit,
onEnableBackups: () -> Unit,
onLearnMore: () -> Unit
) {
Scaffolds.Settings(
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24),
title = "Chat backups" // TODO [message-backups] Finalized copy
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Image(
painter = painterResource(id = R.drawable.ic_signal_logo_large), // TODO [message-backups] Final image asset
contentDescription = null,
modifier = Modifier
.padding(top = 48.dp)
.size(88.dp)
)
}
item {
Text(
text = "Chat Backups", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 15.dp)
)
}
item {
Text(
text = "Back up your messages and media and using Signals secure, end-to-end encrypted storage service. Never lose a message when you get a new phone or reinstall Signal.", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
Column(
modifier = Modifier.padding(top = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
NotableFeatureRow(
painter = painterResource(id = R.drawable.symbol_lock_compact_20),
text = "End-to-end Encrypted" // TODO [message-backups] Finalized copy
)
NotableFeatureRow(
painter = painterResource(id = R.drawable.symbol_check_square_compact_20),
text = "Optional, always" // TODO [message-backups] Finalized copy
)
NotableFeatureRow(
painter = painterResource(id = R.drawable.symbol_trash_compact_20),
text = "Delete your backup anytime" // TODO [message-backups] Finalized copy
)
}
}
}
Buttons.LargePrimary(
onClick = onEnableBackups,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Enable backups" // TODO [message-backups] Finalized copy
)
}
TextButton(
onClick = onLearnMore,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = "Learn more" // TODO [message-backups] Finalized copy
)
}
}
}
}
@Preview
@Composable
private fun MessageBackupsEducationSheetPreview() {
Previews.Preview {
MessageBackupsEducationScreen(
onNavigationClick = {},
onEnableBackups = {},
onLearnMore = {}
)
}
}
@Preview
@Composable
private fun NotableFeatureRowPreview() {
Previews.Preview {
NotableFeatureRow(
painter = painterResource(id = R.drawable.symbol_lock_compact_20),
text = "Notable feature information"
)
}
}
@Composable
private fun NotableFeatureRow(
painter: Painter,
text: String
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(end = 8.dp)
.size(32.dp)
.background(color = SignalTheme.colors.colorSurface2, shape = CircleShape)
.padding(6.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.util.viewModel
class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContent {
SignalTheme {
val state by viewModel.state
val navController = rememberNavController()
fun MessageBackupsScreen.next() {
val nextScreen = viewModel.goToNextScreen(this)
if (nextScreen != this) {
navController.navigate(nextScreen.name)
}
}
fun NavController.popOrFinish() {
if (popBackStack()) {
return
}
finishAfterTransition()
}
LaunchedEffect(Unit) {
navController.setLifecycleOwner(this@MessageBackupsFlowActivity)
navController.setOnBackPressedDispatcher(this@MessageBackupsFlowActivity.onBackPressedDispatcher)
navController.enableOnBackPressed(true)
}
NavHost(
navController = navController,
startDestination = MessageBackupsScreen.EDUCATION.name,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) {
composable(route = MessageBackupsScreen.EDUCATION.name) {
MessageBackupsEducationScreen(
onNavigationClick = navController::popOrFinish,
onEnableBackups = { MessageBackupsScreen.EDUCATION.next() },
onLearnMore = {}
)
}
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
MessageBackupsPinEducationScreen(
onNavigationClick = navController::popOrFinish,
onGeneratePinClick = {},
onUseCurrentPinClick = { MessageBackupsScreen.PIN_EDUCATION.next() },
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
)
}
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
MessageBackupsPinConfirmationScreen(
pin = state.pin,
onPinChanged = viewModel::onPinEntryUpdated,
pinKeyboardType = state.pinKeyboardType,
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
onNextClick = { MessageBackupsScreen.PIN_CONFIRMATION.next() }
)
}
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
selectedBackupsType = state.selectedMessageBackupsType,
availableBackupsTypes = state.availableBackupsTypes,
onMessageBackupsTypeSelected = viewModel::onMessageBackupsTypeUpdated,
onNavigationClick = navController::popOrFinish,
onReadMoreClicked = {},
onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() }
)
}
dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) {
MessageBackupsCheckoutSheet(
messageBackupsType = state.selectedMessageBackupsType!!,
availablePaymentGateways = state.availablePaymentGateways,
onDismissRequest = navController::popOrFinish,
onPaymentGatewaySelected = {
viewModel.onPaymentGatewayUpdated(it)
MessageBackupsScreen.CHECKOUT_SHEET.next()
}
)
}
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
class MessageBackupsFlowRepository

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
data class MessageBackupsFlowState(
val selectedMessageBackupsType: MessageBackupsType? = null,
val availableBackupsTypes: List<MessageBackupsType> = emptyList(),
val selectedPaymentGateway: GatewayResponse.Gateway? = null,
val availablePaymentGateways: List<GatewayResponse.Gateway> = emptyList(),
val pin: String = "",
val pinKeyboardType: PinKeyboardType = SignalStore.pinValues().keyboardType
)

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
class MessageBackupsFlowViewModel : ViewModel() {
private val internalState = mutableStateOf(MessageBackupsFlowState())
val state: State<MessageBackupsFlowState> = internalState
fun goToNextScreen(currentScreen: MessageBackupsScreen): MessageBackupsScreen {
return when (currentScreen) {
MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.PIN_EDUCATION
MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.PIN_CONFIRMATION
MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState()
MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState()
MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState()
MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.COMPLETED
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
}
}
fun onPinEntryUpdated(pin: String) {
internalState.value = state.value.copy(pin = pin)
}
fun onPinKeyboardTypeUpdated(pinKeyboardType: PinKeyboardType) {
internalState.value = state.value.copy(pinKeyboardType = pinKeyboardType)
}
fun onPaymentGatewayUpdated(gateway: GatewayResponse.Gateway) {
internalState.value = state.value.copy(selectedPaymentGateway = gateway)
}
fun onMessageBackupsTypeUpdated(messageBackupsType: MessageBackupsType) {
internalState.value = state.value.copy(selectedMessageBackupsType = messageBackupsType)
}
private fun validatePinAndUpdateState(): MessageBackupsScreen {
return MessageBackupsScreen.TYPE_SELECTION
}
private fun validateTypeAndUpdateState(): MessageBackupsScreen {
return MessageBackupsScreen.CHECKOUT_SHEET
}
private fun validateGatewayAndUpdateState(): MessageBackupsScreen {
return MessageBackupsScreen.PROCESS_PAYMENT
}
}

View File

@@ -0,0 +1,198 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
/**
* Screen which requires the user to enter their pin before enabling backups.
*/
@Composable
fun MessageBackupsPinConfirmationScreen(
pin: String,
onPinChanged: (String) -> Unit,
pinKeyboardType: PinKeyboardType,
onPinKeyboardTypeSelected: (PinKeyboardType) -> Unit,
onNextClick: () -> Unit
) {
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Text(
text = "Enter your PIN", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 40.dp)
)
}
item {
Text(
text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
// TODO [message-backups] Confirm default focus state
val keyboardType = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password
}
}
TextField(
value = pin,
onValueChange = onPinChanged,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
keyboardActions = KeyboardActions(
onDone = { onNextClick() }
),
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType
),
modifier = Modifier
.padding(top = 72.dp)
.fillMaxWidth()
.focusRequester(focusRequester)
)
}
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 48.dp)
) {
PinKeyboardTypeToggle(
pinKeyboardType = pinKeyboardType,
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
)
}
}
}
Box(
contentAlignment = Alignment.BottomEnd,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick
) {
Text(
text = "Next" // TODO [message-backups] Finalized copy
)
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
@Preview
@Composable
private fun MessageBackupsPinConfirmationScreenPreview() {
Previews.Preview {
MessageBackupsPinConfirmationScreen(
pin = "",
onPinChanged = {},
pinKeyboardType = PinKeyboardType.ALPHA_NUMERIC,
onPinKeyboardTypeSelected = {},
onNextClick = {}
)
}
}
@Preview
@Composable
private fun PinKeyboardTypeTogglePreview() {
Previews.Preview {
var type by remember { mutableStateOf(PinKeyboardType.ALPHA_NUMERIC) }
PinKeyboardTypeToggle(
pinKeyboardType = type,
onPinKeyboardTypeSelected = { type = it }
)
}
}
@Composable
private fun PinKeyboardTypeToggle(
pinKeyboardType: PinKeyboardType,
onPinKeyboardTypeSelected: (PinKeyboardType) -> Unit
) {
val callback = remember(pinKeyboardType) {
{ onPinKeyboardTypeSelected(pinKeyboardType.other) }
}
val iconRes = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> R.drawable.symbol_keyboard_24
PinKeyboardType.ALPHA_NUMERIC -> R.drawable.symbol_number_pad_24
}
}
TextButton(onClick = callback) {
Icon(
painter = painterResource(id = iconRes),
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = "Switch keyboard" // TODO [message-backups] Finalized copy
)
}
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
/**
* Explanation screen that details how the user's pin is utilized with backups,
* and how long they should make their pin.
*/
@Composable
fun MessageBackupsPinEducationScreen(
onNavigationClick: () -> Unit,
onGeneratePinClick: () -> Unit,
onUseCurrentPinClick: () -> Unit,
recommendedPinSize: Int
) {
Scaffolds.Settings(
title = "Backup type", // TODO [message-backups] Finalized copy
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Image(
painter = painterResource(id = R.drawable.ic_signal_logo_large), // TODO [message-backups] Finalized image
contentDescription = null,
modifier = Modifier
.padding(top = 48.dp)
.size(88.dp)
)
}
item {
Text(
text = "PINs protect your backup", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = "Your Signal PIN lets you restore your backup when you re-install Signal. For increased security, we recommend updating to a new $recommendedPinSize-digit PIN.", // TODO [message-backups] Finalized copy
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = "If you forget your PIN, you will not be able to restore your backup. You can change your PIN at any time in settings.", // TODO [message-backups] Finalized copy
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
}
}
Buttons.LargePrimary(
onClick = onGeneratePinClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy
)
}
TextButton(
onClick = onUseCurrentPinClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = "Use current Signal PIN" // TODO [message-backups] Finalized copy
)
}
}
}
}
@Preview
@Composable
private fun MessageBackupsPinScreenPreview() {
Previews.Preview {
MessageBackupsPinEducationScreen(
onNavigationClick = {},
onGeneratePinClick = {},
onUseCurrentPinClick = {},
recommendedPinSize = 16
)
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
enum class MessageBackupsScreen {
EDUCATION,
PIN_EDUCATION,
PIN_CONFIRMATION,
TYPE_SELECTION,
CHECKOUT_SHEET,
PROCESS_PAYMENT,
COMPLETED
}

View File

@@ -0,0 +1,302 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
/**
* Screen which allows the user to select their preferred backup type.
*/
@OptIn(ExperimentalTextApi::class)
@Composable
fun MessageBackupsTypeSelectionScreen(
selectedBackupsType: MessageBackupsType?,
availableBackupsTypes: List<MessageBackupsType>,
onMessageBackupsTypeSelected: (MessageBackupsType) -> Unit,
onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit
) {
Scaffolds.Settings(
title = "",
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.fillMaxSize()
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Image(
painter = painterResource(id = R.drawable.ic_signal_logo_large), // TODO [message-backups] Finalized art asset
contentDescription = null,
modifier = Modifier.size(88.dp)
)
}
item {
Text(
text = "Choose your backup type", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
// TODO [message-backups] Finalized copy
val primaryColor = MaterialTheme.colorScheme.primary
val readMoreString = buildAnnotatedString {
append("All backups are end-to-end encrypted. Signal is a non-profit—paying for backups helps support our mission. ")
withAnnotation(tag = "URL", annotation = "read-more") {
withStyle(
style = SpanStyle(
color = primaryColor
)
) {
append("Read more")
}
}
}
ClickableText(
text = readMoreString,
style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center),
onClick = { offset ->
readMoreString
.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { onReadMoreClicked() }
},
modifier = Modifier.padding(top = 8.dp)
)
}
itemsIndexed(
availableBackupsTypes,
{ _, item -> item.title }
) { index, item ->
MessageBackupsTypeBlock(
messageBackupsType = item,
isSelected = item == selectedBackupsType,
onSelected = { onMessageBackupsTypeSelected(item) },
modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp)
)
}
}
Buttons.LargePrimary(
onClick = onNextClicked,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Text(
text = "Next" // TODO [message-backups] Finalized copy
)
}
}
}
}
@Preview
@Composable
private fun MessageBackupsTypeSelectionScreenPreview() {
val freeTier = MessageBackupsType(
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
title = "Text + 30 days of media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Last 30 days of media"
)
)
)
val paidTier = MessageBackupsType(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
title = "Text + All your media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Full media backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "1TB of storage (~250K photos)"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
label = "Thanks for supporting Signal!"
)
)
)
var selectedBackupsType by remember { mutableStateOf(freeTier) }
Previews.Preview {
MessageBackupsTypeSelectionScreen(
selectedBackupsType = selectedBackupsType,
availableBackupsTypes = listOf(freeTier, paidTier),
onMessageBackupsTypeSelected = { selectedBackupsType = it },
onNavigationClick = {},
onReadMoreClicked = {},
onNextClicked = {}
)
}
}
@Composable
fun MessageBackupsTypeBlock(
messageBackupsType: MessageBackupsType,
isSelected: Boolean,
onSelected: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
val borderColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
Color.Transparent
}
val background = if (isSelected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
SignalTheme.colors.colorSurface2
}
Column(
modifier = modifier
.fillMaxWidth()
.background(color = background, shape = RoundedCornerShape(18.dp))
.border(width = 2.dp, color = borderColor, shape = RoundedCornerShape(18.dp))
.clip(shape = RoundedCornerShape(18.dp))
.clickable(onClick = onSelected, enabled = enabled)
.padding(vertical = 16.dp, horizontal = 20.dp)
) {
Text(
text = formatCostPerMonth(messageBackupsType.pricePerMonth),
style = MaterialTheme.typography.titleSmall
)
Text(
text = messageBackupsType.title,
style = MaterialTheme.typography.titleMedium
)
Column(
verticalArrangement = spacedBy(4.dp),
modifier = Modifier
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
messageBackupsType.features.forEach {
MessageBackupsTypeFeatureRow(messageBackupsTypeFeature = it)
}
}
}
}
@Composable
private fun formatCostPerMonth(pricePerMonth: FiatMoney): String {
return if (pricePerMonth.amount == BigDecimal.ZERO) {
"Free"
} else {
"${FiatMoneyUtil.format(LocalContext.current.resources, pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())}/month"
}
}
@Composable
private fun MessageBackupsTypeFeatureRow(messageBackupsTypeFeature: MessageBackupsTypeFeature) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = messageBackupsTypeFeature.iconResourceId),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = messageBackupsTypeFeature.label,
style = MaterialTheme.typography.bodyLarge
)
}
}
data class MessageBackupsType(
val pricePerMonth: FiatMoney,
val title: String,
val features: ImmutableList<MessageBackupsTypeFeature>
)
data class MessageBackupsTypeFeature(
val iconResourceId: Int,
val label: String
)

View File

@@ -196,7 +196,6 @@ import java.util.stream.Collectors;
import kotlin.Unit;
import static android.app.Activity.RESULT_CANCELED;
import static android.app.Activity.RESULT_OK;

View File

@@ -52,6 +52,15 @@ class SavedStateViewModelFactory<MODEL : ViewModel>(
}
}
@MainThread
inline fun <reified VM : ViewModel> ComponentActivity.viewModel(
noinline create: () -> VM
): Lazy<VM> {
return viewModels(
factoryProducer = ViewModelFactory.factoryProducer(create)
)
}
@MainThread
inline fun <reified VM : ViewModel> Fragment.viewModel(
noinline create: () -> VM