[#1235] Server switching
- Closes #1235 - Note that failures and server saving success are reported to the UI with the Android system Snackbar or via the existing Textfield’s error bottom text until we define popup design as filed in #1242 - Changelog update
This commit is contained in:
parent
8f44794f9e
commit
cc5f3504fe
|
@ -12,6 +12,8 @@ directly impact users rather than highlighting other key architectural updates.*
|
|||
### Added
|
||||
- Advanced Settings screen that provides more technical options like Export private data, Recovery phrase, or
|
||||
Choose server
|
||||
- A new Server switching screen was added. Its purpose is to enable switching between predefined and custom
|
||||
lightwalletd servers in runtime.
|
||||
|
||||
## [0.2.0 (560)] - 2024-02-27
|
||||
|
||||
|
|
|
@ -34,3 +34,8 @@ val LightWalletEndpoint.Companion.Testnet
|
|||
DEFAULT_PORT,
|
||||
isSecure = true
|
||||
)
|
||||
|
||||
const val MIN_PORT_NUMBER = 1
|
||||
const val MAX_PORT_NUMBER = 65535
|
||||
|
||||
fun LightWalletEndpoint.isValid() = host.isNotBlank() && port in MIN_PORT_NUMBER..MAX_PORT_NUMBER
|
|
@ -7,12 +7,16 @@ import androidx.compose.foundation.gestures.awaitFirstDown
|
|||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ButtonDefaults.buttonColors
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
@ -36,6 +40,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
|
||||
@Preview
|
||||
|
@ -45,6 +50,7 @@ private fun ButtonComposablePreview() {
|
|||
GradientSurface {
|
||||
Column(Modifier.padding(ZcashTheme.dimens.spacingDefault)) {
|
||||
PrimaryButton(onClick = { }, text = "Primary")
|
||||
PrimaryButton(onClick = { }, text = "Primary", showProgressBar = true)
|
||||
SecondaryButton(onClick = { }, text = "Secondary")
|
||||
TertiaryButton(onClick = { }, text = "Tertiary")
|
||||
TertiaryButton(onClick = { }, text = "Tertiary", enabled = false)
|
||||
|
@ -56,13 +62,14 @@ private fun ButtonComposablePreview() {
|
|||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "LongMethod")
|
||||
fun PrimaryButton(
|
||||
onClick: () -> Unit,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
buttonColor: Color = MaterialTheme.colorScheme.primary,
|
||||
enabled: Boolean = true,
|
||||
showProgressBar: Boolean = false,
|
||||
buttonColor: Color = MaterialTheme.colorScheme.primary,
|
||||
textColor: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
textStyle: TextStyle = ZcashTheme.extendedTypography.buttonText,
|
||||
outerPaddingValues: PaddingValues =
|
||||
|
@ -100,12 +107,52 @@ fun PrimaryButton(
|
|||
),
|
||||
onClick = onClick,
|
||||
) {
|
||||
Text(
|
||||
style = textStyle,
|
||||
textAlign = TextAlign.Center,
|
||||
text = text.uppercase(),
|
||||
color = textColor
|
||||
)
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val (title, spacer, progress) = createRefs()
|
||||
|
||||
Text(
|
||||
style = textStyle,
|
||||
textAlign = TextAlign.Center,
|
||||
text = text.uppercase(),
|
||||
color = textColor,
|
||||
modifier =
|
||||
Modifier.constrainAs(title) {
|
||||
top.linkTo(parent.top)
|
||||
bottom.linkTo(parent.bottom)
|
||||
start.linkTo(parent.start)
|
||||
end.linkTo(parent.end)
|
||||
}
|
||||
)
|
||||
|
||||
if (showProgressBar) {
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(12.dp)
|
||||
.constrainAs(spacer) {
|
||||
top.linkTo(parent.top)
|
||||
bottom.linkTo(parent.bottom)
|
||||
start.linkTo(title.end)
|
||||
end.linkTo(progress.start)
|
||||
}
|
||||
)
|
||||
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(18.dp)
|
||||
.constrainAs(progress) {
|
||||
top.linkTo(parent.top)
|
||||
bottom.linkTo(parent.bottom)
|
||||
start.linkTo(spacer.end)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
package co.electriccoin.zcash.ui.design.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.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.platform.testTag
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ComposablePreview() {
|
||||
ZcashTheme(forceDarkMode = false) {
|
||||
RadioButton(
|
||||
text = "test",
|
||||
selected = true,
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadioButton(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
testTag: String? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentSize()
|
||||
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
|
||||
.clickable { onClick() }
|
||||
.then(
|
||||
if (testTag != null) {
|
||||
Modifier.testTag(testTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
colors =
|
||||
RadioButtonDefaults.colors(
|
||||
selectedColor = ZcashTheme.colors.radioButtonColor,
|
||||
unselectedColor = ZcashTheme.colors.radioButtonColor,
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = ZcashTheme.extendedTypography.radioButton,
|
||||
color = ZcashTheme.colors.radioButtonTextColor,
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
top = 16.dp,
|
||||
bottom = 16.dp,
|
||||
start = 0.dp,
|
||||
end = ZcashTheme.dimens.spacingDefault
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -2,8 +2,10 @@ package co.electriccoin.zcash.ui.design.component
|
|||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
|
@ -17,6 +19,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
@ -51,7 +54,7 @@ fun FormTextField(
|
|||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = ZcashTheme.colors.textDisabled,
|
||||
errorContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Green,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent
|
||||
),
|
||||
|
@ -61,57 +64,66 @@ fun FormTextField(
|
|||
withBorder: Boolean = true,
|
||||
bringIntoViewRequester: BringIntoViewRequester? = null,
|
||||
minHeight: Dp = ZcashTheme.dimens.textFieldDefaultHeight,
|
||||
testTag: String? = ""
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val composedTextFieldModifier =
|
||||
modifier
|
||||
.defaultMinSize(minHeight = minHeight)
|
||||
.onFocusEvent { focusState ->
|
||||
bringIntoViewRequester?.run {
|
||||
if (focusState.isFocused) {
|
||||
coroutineScope.launch {
|
||||
bringIntoView()
|
||||
Column(modifier = Modifier.then(modifier)) {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder =
|
||||
if (enabled) {
|
||||
placeholder
|
||||
} else {
|
||||
null
|
||||
},
|
||||
textStyle = textStyle,
|
||||
keyboardOptions = keyboardOptions,
|
||||
colors = colors,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = minHeight)
|
||||
.onFocusEvent { focusState ->
|
||||
bringIntoViewRequester?.run {
|
||||
if (focusState.isFocused) {
|
||||
coroutineScope.launch {
|
||||
bringIntoView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (withBorder) {
|
||||
modifier.border(width = 1.dp, color = ZcashTheme.colors.textFieldFrame)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder =
|
||||
if (enabled) {
|
||||
placeholder
|
||||
} else {
|
||||
null
|
||||
},
|
||||
textStyle = textStyle,
|
||||
keyboardOptions = keyboardOptions,
|
||||
colors = colors,
|
||||
modifier = composedTextFieldModifier,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
keyboardActions = keyboardActions,
|
||||
shape = shape,
|
||||
enabled = enabled
|
||||
)
|
||||
|
||||
if (!error.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
|
||||
|
||||
BodySmall(
|
||||
text = error,
|
||||
color = ZcashTheme.colors.textFieldError,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
.then(
|
||||
if (withBorder) {
|
||||
Modifier.border(width = 1.dp, color = ZcashTheme.colors.textFieldFrame)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (testTag.isNullOrEmpty()) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.testTag(testTag)
|
||||
}
|
||||
),
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
keyboardActions = keyboardActions,
|
||||
shape = shape,
|
||||
enabled = enabled
|
||||
)
|
||||
|
||||
if (!error.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
|
||||
|
||||
BodySmall(
|
||||
text = error,
|
||||
color = ZcashTheme.colors.textFieldError,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,6 +43,8 @@ data class ExtendedColors(
|
|||
val darkDividerColor: Color,
|
||||
val tabTextColor: Color,
|
||||
val panelBackgroundColor: Color,
|
||||
val radioButtonColor: Color,
|
||||
val radioButtonTextColor: Color,
|
||||
) {
|
||||
@Composable
|
||||
fun surfaceGradient() =
|
||||
|
|
|
@ -56,6 +56,9 @@ internal object Dark {
|
|||
val navigationButton = Color(0xFFFFFFFF)
|
||||
val navigationButtonPressed = Color(0xFFFFFFFF)
|
||||
|
||||
val radioButtonColor = Color(0xFF070707)
|
||||
val radioButtonTextColor = Color(0xFF4E4E4E)
|
||||
|
||||
val circularProgressBarSmall = Color(0xFF8B8A8A)
|
||||
val circularProgressBarScreen = Color(0xFFFFFFFF)
|
||||
val linearProgressBarTrack = Color(0xFFD9D9D9)
|
||||
|
@ -120,6 +123,9 @@ internal object Light {
|
|||
val navigationButton = Color(0xFFFFFFFF)
|
||||
val navigationButtonPressed = Color(0xFFFFFFFF)
|
||||
|
||||
val radioButtonColor = Color(0xFF070707)
|
||||
val radioButtonTextColor = Color(0xFF4E4E4E)
|
||||
|
||||
val circularProgressBarSmall = Color(0xFF8B8A8A)
|
||||
val circularProgressBarScreen = Color(0xFF000000)
|
||||
val linearProgressBarTrack = Color(0xFFD9D9D9)
|
||||
|
@ -202,6 +208,8 @@ internal val DarkExtendedColorPalette =
|
|||
darkDividerColor = Dark.darkDividerColor,
|
||||
tabTextColor = Dark.tabTextColor,
|
||||
panelBackgroundColor = Dark.panelBackgroundColor,
|
||||
radioButtonColor = Dark.radioButtonColor,
|
||||
radioButtonTextColor = Dark.radioButtonTextColor,
|
||||
)
|
||||
|
||||
internal val LightExtendedColorPalette =
|
||||
|
@ -241,6 +249,8 @@ internal val LightExtendedColorPalette =
|
|||
darkDividerColor = Light.darkDividerColor,
|
||||
tabTextColor = Light.tabTextColor,
|
||||
panelBackgroundColor = Light.panelBackgroundColor,
|
||||
radioButtonColor = Dark.radioButtonColor,
|
||||
radioButtonTextColor = Dark.radioButtonTextColor,
|
||||
)
|
||||
|
||||
@Suppress("CompositionLocalAllowlist")
|
||||
|
@ -282,5 +292,7 @@ internal val LocalExtendedColors =
|
|||
darkDividerColor = Color.Unspecified,
|
||||
tabTextColor = Color.Unspecified,
|
||||
panelBackgroundColor = Color.Unspecified,
|
||||
radioButtonColor = Color.Unspecified,
|
||||
radioButtonTextColor = Color.Unspecified,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -170,6 +170,7 @@ data class ExtendedTypography(
|
|||
val textFieldBirthday: TextStyle,
|
||||
val textNavTab: TextStyle,
|
||||
val referenceSmall: TextStyle,
|
||||
val radioButton: TextStyle,
|
||||
)
|
||||
|
||||
@Suppress("CompositionLocalAllowlist")
|
||||
|
@ -269,6 +270,11 @@ val LocalExtendedTypography =
|
|||
referenceSmall =
|
||||
PrimaryTypography.bodySmall.copy(
|
||||
fontSize = 13.sp
|
||||
),
|
||||
radioButton =
|
||||
PrimaryTypography.bodySmall.copy(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ android {
|
|||
"src/main/res/ui/export_data",
|
||||
"src/main/res/ui/history",
|
||||
"src/main/res/ui/home",
|
||||
"src/main/res/ui/choose_server",
|
||||
"src/main/res/ui/new_wallet_recovery",
|
||||
"src/main/res/ui/onboarding",
|
||||
"src/main/res/ui/receive",
|
||||
|
|
|
@ -15,6 +15,8 @@ import cash.z.ecc.android.sdk.model.Zatoshi
|
|||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import cash.z.ecc.android.sdk.type.ConsensusMatchType
|
||||
import cash.z.ecc.android.sdk.type.ServerValidation
|
||||
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
|
@ -155,6 +157,13 @@ internal class MockSynchronizer : CloseableSynchronizer {
|
|||
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
|
||||
}
|
||||
|
||||
override suspend fun validateServerEndpoint(
|
||||
context: Context,
|
||||
endpoint: LightWalletEndpoint
|
||||
): ServerValidation {
|
||||
error("Intentionally not implemented in ${MockSynchronizer::class.simpleName} implementation.")
|
||||
}
|
||||
|
||||
override suspend fun getExistingDataDbFilePath(
|
||||
context: Context,
|
||||
network: ZcashNetwork,
|
||||
|
|
|
@ -11,6 +11,7 @@ import co.electriccoin.zcash.ui.NavigationArguments.SEND_MEMO
|
|||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_RECIPIENT_ADDRESS
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.HISTORY
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.HOME
|
||||
|
@ -23,6 +24,7 @@ import co.electriccoin.zcash.ui.configuration.ConfigurationEntries
|
|||
import co.electriccoin.zcash.ui.configuration.RemoteConfig
|
||||
import co.electriccoin.zcash.ui.screen.about.WrapAbout
|
||||
import co.electriccoin.zcash.ui.screen.advancedsettings.WrapAdvancedSettings
|
||||
import co.electriccoin.zcash.ui.screen.chooseserver.WrapChooseServer
|
||||
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
|
||||
import co.electriccoin.zcash.ui.screen.history.WrapHistory
|
||||
import co.electriccoin.zcash.ui.screen.home.WrapHome
|
||||
|
@ -102,8 +104,14 @@ internal fun MainActivity.Navigation() {
|
|||
navController.navigateJustOnce(SEED_RECOVERY)
|
||||
},
|
||||
goChooseServer = {
|
||||
// TODO [#1235]: Create screen for selecting the lightwalletd server
|
||||
// TODO [#1235]: https://github.com/Electric-Coin-Company/zashi-android/issues/1235
|
||||
navController.navigateJustOnce(CHOOSE_SERVER)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(CHOOSE_SERVER) {
|
||||
WrapChooseServer(
|
||||
goBack = {
|
||||
navController.popBackStackJustOnce(CHOOSE_SERVER)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -194,6 +202,7 @@ object NavigationTargets {
|
|||
const val EXPORT_PRIVATE_DATA = "export_private_data"
|
||||
const val HISTORY = "history"
|
||||
const val HOME = "home"
|
||||
const val CHOOSE_SERVER = "choose_server"
|
||||
const val RECEIVE = "receive"
|
||||
const val REQUEST = "request"
|
||||
const val SCAN = "scan"
|
||||
|
|
|
@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
import cash.z.ecc.android.bip39.toSeed
|
||||
import cash.z.ecc.android.sdk.SdkSynchronizer
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.WalletCoordinator
|
||||
import cash.z.ecc.android.sdk.WalletInitMode
|
||||
|
@ -283,6 +284,18 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
|||
fun resetSdk() {
|
||||
walletCoordinator.resetSdk()
|
||||
}
|
||||
|
||||
/**
|
||||
* This safely and asynchronously stops [Synchronizer].
|
||||
*/
|
||||
fun closeSynchronizer() {
|
||||
val synchronizer = synchronizer.value
|
||||
if (null != synchronizer) {
|
||||
viewModelScope.launch {
|
||||
(synchronizer as SdkSynchronizer).close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
@file:Suppress("ktlint:standard:filename")
|
||||
|
||||
package co.electriccoin.zcash.ui.screen.chooseserver
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.internal.Twig
|
||||
import cash.z.ecc.android.sdk.model.PersistableWallet
|
||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.android.sdk.type.ServerValidation
|
||||
import cash.z.ecc.sdk.type.fromResources
|
||||
import co.electriccoin.zcash.ui.MainActivity
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.common.viewmodel.SecretState
|
||||
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
|
||||
import co.electriccoin.zcash.ui.screen.chooseserver.view.ChooseServer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
internal fun MainActivity.WrapChooseServer(goBack: () -> Unit) {
|
||||
val secretState = walletViewModel.secretState.collectAsStateWithLifecycle().value
|
||||
|
||||
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
||||
|
||||
WrapChooseServer(
|
||||
activity = this,
|
||||
goBack = goBack,
|
||||
secretState = secretState,
|
||||
synchronizer = synchronizer,
|
||||
onSynchronizerClose = {
|
||||
walletViewModel.closeSynchronizer()
|
||||
},
|
||||
onWalletPersist = {
|
||||
walletViewModel.persistExistingWallet(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
private fun WrapChooseServer(
|
||||
activity: MainActivity,
|
||||
goBack: () -> Unit,
|
||||
onSynchronizerClose: () -> Unit,
|
||||
onWalletPersist: (PersistableWallet) -> Unit,
|
||||
secretState: SecretState,
|
||||
synchronizer: Synchronizer?,
|
||||
) {
|
||||
if (synchronizer == null || secretState !is SecretState.Ready) {
|
||||
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
|
||||
// TODO [#1146]: Improve this by allowing screen composition and updating it after the data is available
|
||||
// TODO [#1146]: https://github.com/Electric-Coin-Company/zashi-android/issues/1146
|
||||
CircularScreenProgressIndicator()
|
||||
} else {
|
||||
val wallet = secretState.persistableWallet
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var validationResult: ServerValidation by remember { mutableStateOf(ServerValidation.Valid) }
|
||||
|
||||
val onCheckedBack = {
|
||||
if (validationResult !is ServerValidation.Running) {
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { onCheckedBack() }
|
||||
|
||||
ChooseServer(
|
||||
availableServers = AvailableServerProvider.toList(ZcashNetwork.fromResources(activity)),
|
||||
onBack = onCheckedBack,
|
||||
onServerChange = { newEndpoint ->
|
||||
scope.launch {
|
||||
validationResult = ServerValidation.Running
|
||||
validationResult = synchronizer.validateServerEndpoint(activity, newEndpoint)
|
||||
|
||||
Twig.debug { "Choose Server: Validation result: $validationResult" }
|
||||
|
||||
when (validationResult) {
|
||||
ServerValidation.Valid -> {
|
||||
onSynchronizerClose()
|
||||
|
||||
val newWallet =
|
||||
wallet.copy(
|
||||
endpoint = newEndpoint
|
||||
)
|
||||
|
||||
Twig.debug { "Choose Server: New wallet: ${newWallet.toSafeString()}" }
|
||||
|
||||
onWalletPersist(newWallet)
|
||||
|
||||
snackbarHostState.showSnackbar(
|
||||
message = activity.getString(R.string.choose_server_saved),
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
is ServerValidation.InValid -> {
|
||||
Twig.error { "Choose Server: Failed to validate the new endpoint: $newEndpoint" }
|
||||
}
|
||||
else -> {
|
||||
// Should not happen
|
||||
Twig.warn { "Choose Server: Server validation state: $validationResult" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHostState = snackbarHostState,
|
||||
validationResult = validationResult,
|
||||
wallet = wallet,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package co.electriccoin.zcash.ui.screen.chooseserver
|
||||
|
||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.sdk.extension.Mainnet
|
||||
import cash.z.ecc.sdk.extension.Testnet
|
||||
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
// TODO [#1273]: Add ChooseServer Tests #1273
|
||||
// TODO [#1273]: https://github.com/Electric-Coin-Company/zashi-android/issues/1273
|
||||
|
||||
object AvailableServerProvider {
|
||||
// North America | [na.lightwalletd.com](http://na.lightwalletd.com/) | 443
|
||||
// South America | [sa.lightwalletd.com](http://sa.lightwalletd.com/) | 443
|
||||
// Europe & Africa | [eu.lightwalletd.com](http://eu.lightwalletd.com/) | 443
|
||||
// Asia & Oceania | [ai.lightwalletd.com](http://ai.lightwalletd.com/) | 443
|
||||
// Plus current network defaults:
|
||||
// Mainnet: mainnet.lightwalletd.com | 9067
|
||||
// Testnet: lightwalletd.testnet.electriccoin.co | 9067
|
||||
|
||||
private const val HOST_NA = "na.lightwalletd.com" // NON-NLS
|
||||
private const val HOST_SA = "sa.lightwalletd.com" // NON-NLS
|
||||
private const val HOST_EU = "eu.lightwalletd.com" // NON-NLS
|
||||
private const val HOST_AI = "ai.lightwalletd.com" // NON-NLS
|
||||
private const val PORT = 443
|
||||
|
||||
fun toList(network: ZcashNetwork) =
|
||||
buildList {
|
||||
if (network == ZcashNetwork.Mainnet) {
|
||||
add(LightWalletEndpoint.Mainnet)
|
||||
add(LightWalletEndpoint(HOST_NA, PORT, true))
|
||||
add(LightWalletEndpoint(HOST_SA, PORT, true))
|
||||
add(LightWalletEndpoint(HOST_EU, PORT, true))
|
||||
add(LightWalletEndpoint(HOST_AI, PORT, true))
|
||||
} else {
|
||||
add(LightWalletEndpoint.Testnet)
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package co.electriccoin.zcash.ui.screen.chooseserver
|
||||
|
||||
/**
|
||||
* These are only used for automated testing.
|
||||
*/
|
||||
object ChooseServerTag {
|
||||
const val CHOOSE_SERVER_TOP_APP_BAR = "choose_server_top_app_bar"
|
||||
}
|
|
@ -0,0 +1,354 @@
|
|||
package co.electriccoin.zcash.ui.screen.chooseserver.view
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import cash.z.ecc.android.sdk.internal.Twig
|
||||
import cash.z.ecc.android.sdk.model.PersistableWallet
|
||||
import cash.z.ecc.android.sdk.type.ServerValidation
|
||||
import cash.z.ecc.sdk.extension.isValid
|
||||
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
|
||||
import co.electriccoin.lightwallet.client.model.LightWalletEndpoint
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.design.component.FormTextField
|
||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||
import co.electriccoin.zcash.ui.design.component.PrimaryButton
|
||||
import co.electriccoin.zcash.ui.design.component.RadioButton
|
||||
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
|
||||
import co.electriccoin.zcash.ui.design.component.SubHeader
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import co.electriccoin.zcash.ui.screen.chooseserver.ChooseServerTag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Preview("Choose Server")
|
||||
@Composable
|
||||
private fun PreviewChooseServer() {
|
||||
ZcashTheme(forceDarkMode = false) {
|
||||
GradientSurface {
|
||||
ChooseServer(
|
||||
availableServers = emptyList<LightWalletEndpoint>().toImmutableList(),
|
||||
onBack = {},
|
||||
onServerChange = {},
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
validationResult = ServerValidation.Valid,
|
||||
wallet = PersistableWalletFixture.new(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
fun ChooseServer(
|
||||
availableServers: ImmutableList<LightWalletEndpoint>,
|
||||
onBack: () -> Unit,
|
||||
onServerChange: (LightWalletEndpoint) -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
validationResult: ServerValidation,
|
||||
wallet: PersistableWallet,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ChooseServerTopAppBar(onBack = onBack)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
ChooseServerMainContent(
|
||||
modifier =
|
||||
Modifier
|
||||
.verticalScroll(
|
||||
rememberScrollState()
|
||||
)
|
||||
.padding(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding(),
|
||||
start = ZcashTheme.dimens.screenHorizontalSpacingRegular,
|
||||
end = ZcashTheme.dimens.screenHorizontalSpacingRegular
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
availableServers = availableServers,
|
||||
onServerChange = onServerChange,
|
||||
validationResult = validationResult,
|
||||
wallet = wallet,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseServerTopAppBar(onBack: () -> Unit) {
|
||||
SmallTopAppBar(
|
||||
backText = stringResource(id = R.string.choose_server_back).uppercase(),
|
||||
backContentDescriptionText = stringResource(R.string.choose_server_back_content_description),
|
||||
onBack = onBack,
|
||||
showTitleLogo = true,
|
||||
modifier = Modifier.testTag(ChooseServerTag.CHOOSE_SERVER_TOP_APP_BAR)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun ChooseServerMainContent(
|
||||
availableServers: ImmutableList<LightWalletEndpoint>,
|
||||
onServerChange: (LightWalletEndpoint) -> Unit,
|
||||
validationResult: ServerValidation,
|
||||
wallet: PersistableWallet,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val options =
|
||||
availableServers.toMutableList().apply {
|
||||
if (contains(wallet.endpoint)) {
|
||||
// We define the custom server as secured by default
|
||||
add(LightWalletEndpoint("", -1, true))
|
||||
} else {
|
||||
// Adding previously chosen custom endpoint
|
||||
add(wallet.endpoint)
|
||||
}
|
||||
}.toImmutableList()
|
||||
|
||||
val (selectedOption, setSelectedOption) =
|
||||
rememberSaveable {
|
||||
mutableIntStateOf(options.indexOf(wallet.endpoint))
|
||||
}
|
||||
|
||||
val initialCustomServerValue =
|
||||
options.last().run {
|
||||
if (options.last().isValid()) {
|
||||
stringResource(R.string.choose_server_textfield_value, options.last().host, options.last().port)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
val (customServerValue, setCustomServerValue) =
|
||||
rememberSaveable {
|
||||
mutableStateOf(initialCustomServerValue)
|
||||
}
|
||||
|
||||
val (customServerError, setCustomServerError) =
|
||||
rememberSaveable {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(key1 = validationResult) {
|
||||
when (validationResult) {
|
||||
is ServerValidation.InValid -> {
|
||||
setCustomServerError(context.getString(R.string.choose_server_textfield_error))
|
||||
}
|
||||
else -> {
|
||||
// Expected state: do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
|
||||
|
||||
SubHeader(
|
||||
text = stringResource(id = R.string.choose_server_title),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
|
||||
|
||||
ServerList(
|
||||
options = options,
|
||||
selectedOption = selectedOption,
|
||||
setSelectedOption = setSelectedOption,
|
||||
customServerError = customServerError,
|
||||
setCustomServerError = setCustomServerError,
|
||||
customServerValue = customServerValue,
|
||||
setCustomServerValue = setCustomServerValue,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
|
||||
|
||||
SaveButton(
|
||||
enabled = validationResult != ServerValidation.Running,
|
||||
options = options,
|
||||
customServerValue = customServerValue,
|
||||
onServerChange = {
|
||||
// Check if the selected is different from the current
|
||||
if (it != wallet.endpoint) {
|
||||
onServerChange(it)
|
||||
}
|
||||
},
|
||||
selectedOption = selectedOption,
|
||||
setCustomServerError = setCustomServerError,
|
||||
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.spacingXlarge)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingHuge))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
fun ServerList(
|
||||
options: ImmutableList<LightWalletEndpoint>,
|
||||
customServerError: String?,
|
||||
setCustomServerError: (String?) -> Unit,
|
||||
customServerValue: String,
|
||||
setCustomServerValue: (String) -> Unit,
|
||||
selectedOption: Int,
|
||||
setSelectedOption: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
options.forEachIndexed { index, endpoint ->
|
||||
val isSelected = index == selectedOption
|
||||
|
||||
if (index == options.lastIndex) {
|
||||
Column(
|
||||
modifier = Modifier.animateContentSize()
|
||||
) {
|
||||
LabeledRadioButton(
|
||||
endpoint = endpoint,
|
||||
changeClick = { setSelectedOption(index) },
|
||||
name = stringResource(id = R.string.choose_server_custom),
|
||||
selected = isSelected,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
|
||||
|
||||
FormTextField(
|
||||
value = customServerValue,
|
||||
onValueChange = {
|
||||
setCustomServerError(null)
|
||||
setCustomServerValue(it)
|
||||
},
|
||||
placeholder = {
|
||||
Text(text = stringResource(R.string.choose_server_textfield_hint))
|
||||
},
|
||||
error = customServerError,
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus(true)
|
||||
}
|
||||
),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ZcashTheme.dimens.spacingSmall)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LabeledRadioButton(
|
||||
endpoint = endpoint,
|
||||
changeClick = { setSelectedOption(index) },
|
||||
name = stringResource(id = R.string.choose_server_textfield_value, endpoint.host, endpoint.port),
|
||||
selected = isSelected,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toEndpoint(delimiter: String): LightWalletEndpoint {
|
||||
val parts = split(delimiter)
|
||||
return LightWalletEndpoint(parts[0], parts[1].toInt(), true)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LabeledRadioButton(
|
||||
name: String,
|
||||
endpoint: LightWalletEndpoint,
|
||||
selected: Boolean,
|
||||
changeClick: (LightWalletEndpoint) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
RadioButton(
|
||||
text = name,
|
||||
selected = selected,
|
||||
onClick = { changeClick(endpoint) },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
// This regex validates server URLs with ports while ensuring:
|
||||
// - Valid hostname format (excluding spaces and special characters)
|
||||
// - Port numbers within the valid range (1-65535) and without leading zeros
|
||||
// - Note that this does not cover other URL components like paths or query strings
|
||||
val regex = "^(([^:/?#\\s]+)://)?([^/?#\\s]+):([1-9][0-9]{3}|[1-5][0-9]{2}|[0-9]{1,2})$".toRegex()
|
||||
|
||||
fun validateCustomServerValue(customServer: String): Boolean = regex.matches(customServer)
|
||||
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
fun SaveButton(
|
||||
enabled: Boolean,
|
||||
customServerValue: String,
|
||||
onServerChange: (LightWalletEndpoint) -> Unit,
|
||||
options: ImmutableList<LightWalletEndpoint>,
|
||||
selectedOption: Int,
|
||||
setCustomServerError: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
PrimaryButton(
|
||||
enabled = enabled,
|
||||
showProgressBar = !enabled,
|
||||
text = stringResource(id = R.string.choose_server_save),
|
||||
onClick = {
|
||||
val selectedServer =
|
||||
if (selectedOption == options.lastIndex) {
|
||||
if (!validateCustomServerValue(customServerValue)) {
|
||||
setCustomServerError(context.getString(R.string.choose_server_textfield_error))
|
||||
return@PrimaryButton
|
||||
}
|
||||
|
||||
customServerValue.toEndpoint(context.getString(R.string.choose_server_custom_delimiter))
|
||||
} else {
|
||||
options[selectedOption]
|
||||
}
|
||||
|
||||
Twig.info { "Choose Server: Selected server: $selectedServer" }
|
||||
|
||||
onServerChange(selectedServer)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
|
@ -715,12 +715,20 @@ private fun RestoreBirthdayMainContent(
|
|||
val filteredHeightString = heightString.filter { it.isDigit() }
|
||||
setHeight(filteredHeightString)
|
||||
},
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = ZcashTheme.colors.textDisabled,
|
||||
errorContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = ZcashTheme.colors.darkDividerColor,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(ZcashTheme.dimens.spacingTiny)
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(RestoreTag.BIRTHDAY_TEXT_FIELD),
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = ZcashTheme.extendedTypography.textFieldBirthday,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
|
@ -731,6 +739,7 @@ private fun RestoreBirthdayMainContent(
|
|||
),
|
||||
keyboardActions = KeyboardActions(onAny = {}),
|
||||
withBorder = false,
|
||||
testTag = RestoreTag.BIRTHDAY_TEXT_FIELD
|
||||
)
|
||||
|
||||
Spacer(
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
<resources>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="advanced_settings_back">Back</string>
|
||||
<string name="advanced_settings_back_content_description">Back</string>
|
||||
|
||||
<string name="advanced_settings_backup_wallet">Recovery phrase</string>
|
||||
<string name="advanced_settings_export_private_data">Export private data</string>
|
||||
<string name="advanced_settings_choose_server">Choose a server</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="choose_server_back">Back</string>
|
||||
<string name="choose_server_back_content_description">Back</string>
|
||||
|
||||
<string name="choose_server_title">Server</string>
|
||||
<string name="choose_server_custom">custom</string>
|
||||
<string name="choose_server_custom_delimiter">:</string>
|
||||
<string name="choose_server_textfield_value"><xliff:g id="host" example="example.com">%1$s</xliff:g>:<xliff:g
|
||||
id="port" example="508">%2$d</xliff:g></string>
|
||||
<string name="choose_server_textfield_hint"><host>:<port></string>
|
||||
<string name="choose_server_textfield_error">Invalid server</string>
|
||||
<string name="choose_server_save">Save</string>
|
||||
<string name="choose_server_saved">Server successfully set</string>
|
||||
</resources>
|
Loading…
Reference in New Issue