Add change server ability and respond to design feedback.

- Changed style of input boxes
- Updated button behavior to only enable when values have changed
- Added simple loading screen
- Added error handling messages when the change server fails
- Switched button order and simplified button text
- Added red validation messages below input
- Respond to user input, as they type
- Reformatted title area to match other screens
- Adjusted layouts to be percentage based to work more consistently on smaller screens
- Implemented logic for restoring the original server values
This commit is contained in:
Kevin Gorham 2020-09-25 11:49:09 -04:00
parent d38626c205
commit 5e2f79ba62
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
7 changed files with 371 additions and 132 deletions

View File

@ -17,7 +17,18 @@ object Const {
object Pref {
const val FIRST_USE_VIEW_TX = "const.pref.first_use_view_tx"
const val FEEDBACK_ENABLED = "const.pref.feedback_enabled"
const val SERVER_NAME = "const.pref.server_name"
const val SERVER_HOST = "const.pref.server_host"
const val SERVER_PORT = "const.pref.server_port"
}
/**
* Default values to use application-wide. Ideally, this set of values should remain very short.
*/
object Default {
object Server {
// If you've forked the ECC repo, change this to your hosted lightwalletd instance
const val HOST = "lightwalletd.electriccoin.co"//"your.hosted.lightwalletd.org"
const val PORT = 9067
}
}
}

View File

@ -5,7 +5,10 @@ import android.app.Dialog
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.view.View
import androidx.core.content.getSystemService
import cash.z.ecc.android.sdk.exception.LightWalletException
import cash.z.ecc.android.ui.MainActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -94,4 +97,32 @@ fun Context.showCriticalProcessorError(error: Throwable?, onRetry: () -> Unit =
throw error ?: RuntimeException("Critical error while processing blocks and the user chose to exit.")
}
.show()
}
fun Context.showUpdateServerCriticalError(userFacingMessage: String, onConfirm: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle("Failed to Change Server")
.setMessage(userFacingMessage)
.setCancelable(false)
.setPositiveButton("Ok") { d, _ ->
d.dismiss()
onConfirm()
}
.show()
}
fun Context.showUpdateServerDialog(positiveText: String = "Update", onCancel: () -> Unit = {}, onUpdate: () -> Unit = {}): Dialog {
return MaterialAlertDialogBuilder(this)
.setTitle("Modify Lightwalletd Server?")
.setMessage("WARNING: Entering an invalid or untrusted server might result in misconfiguration or loss of funds!")
.setCancelable(false)
.setPositiveButton(positiveText) { dialog, _ ->
dialog.dismiss()
onUpdate()
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
onCancel
}
.show()
}

View File

@ -1,13 +1,21 @@
package cash.z.ecc.android.ui.settings
import android.content.res.ColorStateList
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import cash.z.ecc.android.di.viewmodel.viewModel
import android.widget.Toast
import androidx.core.widget.doAfterTextChanged
import cash.z.ecc.android.R
import cash.z.ecc.android.ZcashWalletApp
import cash.z.ecc.android.databinding.FragmentSettingsBinding
import cash.z.ecc.android.ext.onClickNavBack
import cash.z.ecc.android.di.viewmodel.viewModel
import cash.z.ecc.android.ext.*
import cash.z.ecc.android.sdk.exception.LightWalletException
import cash.z.ecc.android.sdk.ext.collectWith
import cash.z.ecc.android.sdk.ext.twig
import cash.z.ecc.android.ui.base.BaseFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
class SettingsFragment : BaseFragment<FragmentSettingsBinding>() {
@ -16,61 +24,131 @@ class SettingsFragment : BaseFragment<FragmentSettingsBinding>() {
override fun inflate(inflater: LayoutInflater): FragmentSettingsBinding =
FragmentSettingsBinding.inflate(inflater)
//
// Lifecycle
//
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
getCurrentServer()
binding.hitAreaClose.onClickNavBack()
binding.buttonUpdate.setOnClickListener(View.OnClickListener {
validateServerHost(view)
})
binding.buttonReset.setOnClickListener(View.OnClickListener {
resetServer()
showUpdateServerDialog(view)
})
}
private fun getCurrentServer() {
binding.inputTextLightwalletdServer.setText(viewModel.getServerHost())
binding.inputTextLightwalletdPort.setText(viewModel.getServerPort().toString())
}
private fun resetServer() {
}
private fun validateServerHost(view: View) {
var isError = false
if (binding.inputTextLightwalletdServer.text.toString().contains("http")) {
binding.lightwalletdServer.error = "Please remove http:// or https://"
isError = true
} else {
binding.lightwalletdServer.error = null
}
if (Integer.valueOf(binding.inputTextLightwalletdPort.text.toString()) > 65535) {
binding.lightwalletdPort.error = "Please enter port number below 65535"
isError = true
} else {
binding.lightwalletdPort.error = null
}
if (!isError) {
showUpdateServerDialog(view)
}
}
private fun showUpdateServerDialog(view: View) {
MaterialAlertDialogBuilder(view.context)
.setTitle("Modify lightwalletd Server?")
.setMessage("WARNING: Entering an invalid or compromised lighthttpd server might result in misconfiguration or loss of funds.")
.setCancelable(false)
.setPositiveButton("Update") { dialog, _ ->
dialog.dismiss()
updateServer()
mainActivity?.preventBackPress(this)
viewModel.init()
binding.apply {
groupLoading.gone()
hitAreaExit.onClickNavBack()
buttonReset.setOnClickListener(::onResetClicked)
buttonUpdate.setOnClickListener(::onUpdateClicked)
buttonUpdate.isActivated = true
buttonReset.isActivated = true
inputHost.doAfterTextChanged {
viewModel.pendingHost = it.toString()
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
inputPort.doAfterTextChanged {
viewModel.pendingPortText = it.toString()
}
.show()
}
}
private fun updateServer() {
override fun onResume() {
super.onResume()
viewModel.uiModels.collectWith(resumedScope, ::onUiModelUpdated)
}
//
// Event handlers
//
private fun onResetClicked(unused: View?) {
mainActivity?.hideKeyboard()
context?.showUpdateServerDialog("Restore Defaults") {
resumedScope.launch {
binding.groupLoading.visible()
binding.loadingView.requestFocus()
viewModel.resetServer()
}
}
}
private fun onUpdateClicked(unused: View?) {
mainActivity?.hideKeyboard()
context?.showUpdateServerDialog {
resumedScope.launch {
binding.groupLoading.visible()
binding.loadingView.requestFocus()
viewModel.submit()
}
}
}
private fun onUiModelUpdated(uiModel: SettingsViewModel.UiModel) {
twig("onUiModelUpdated:::::$uiModel")
binding.apply {
if (handleCompletion(uiModel)) return@onUiModelUpdated
// avoid moving the cursor on instances where the change originated from the UI
if (inputHost.text.toString() != uiModel.host) inputHost.setText(uiModel.host)
if (inputPort.text.toString() != uiModel.portText) inputPort.setText(uiModel.portText)
buttonReset.isEnabled = uiModel.submitEnabled
buttonUpdate.isEnabled = uiModel.submitEnabled && !uiModel.hasError
uiModel.hostErrorMessage.let { it ->
textInputLayoutHost.helperText = it
?: R.string.settings_host_helper_text.toAppString()
textInputLayoutHost.setHelperTextColor(it.toHelperTextColor())
}
uiModel.portErrorMessage.let { it ->
textInputLayoutPort.helperText = it
?: R.string.settings_port_helper_text.toAppString()
textInputLayoutPort.setHelperTextColor(it.toHelperTextColor())
}
}
}
/**
* Handle the exit conditions and return true if we're done here.
*/
private fun handleCompletion(uiModel: SettingsViewModel.UiModel): Boolean {
return if (uiModel.changeError != null) {
binding.groupLoading.gone()
onCriticalError(uiModel.changeError)
true
} else {
if (uiModel.complete) {
binding.groupLoading.gone()
mainActivity?.safeNavigate(R.id.nav_home)
Toast.makeText(ZcashWalletApp.instance, "Successfully changed server!", Toast.LENGTH_SHORT).show()
true
}
false
}
}
private fun onCriticalError(error: Throwable) {
val details = if (error is LightWalletException.ChangeServerException.StatusException) {
error.status.description
} else {
error.javaClass.simpleName
}
val message = "An error occured while changing servers. Please verify the info" +
" and try again.\n\nError: $details"
twig(message)
Toast.makeText(ZcashWalletApp.instance, "Failed to change server!", Toast.LENGTH_SHORT).show()
context?.showUpdateServerCriticalError(message)
}
//
// Utilities
//
private fun String?.toHelperTextColor(): ColorStateList {
val color = if (this == null) {
R.color.text_light_dimmed
} else {
R.color.zcashRed
}
return ColorStateList.valueOf(color.toAppColor())
}
}

View File

@ -1,30 +1,99 @@
package cash.z.ecc.android.ui.settings
import androidx.lifecycle.ViewModel
import cash.z.ecc.android.di.module.InitializerModule
import cash.z.ecc.android.ext.Const
import cash.z.ecc.android.lockbox.LockBox
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.ext.twig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.cancellable
import javax.inject.Inject
import javax.inject.Named
import kotlin.properties.Delegates.observable
import kotlin.reflect.KProperty
class SettingsViewModel @Inject constructor() : ViewModel() {
@Inject
lateinit var synchronizer: Synchronizer
fun updateServer(host: String, port: Int) {
// TODO: Update the SecurePrefs here
@Inject
@Named(Const.Name.APP_PREFS)
lateinit var prefs: LockBox
lateinit var uiModels: MutableStateFlow<UiModel>
private lateinit var initialServer: UiModel
var pendingHost by observable("", ::onUpdateModel)
var pendingPortText by observable("", ::onUpdateModel)
private fun getHost(): String {
return prefs[Const.Pref.SERVER_HOST] ?: Const.Default.Server.HOST
}
fun getServerHost(): String {
return InitializerModule.defaultHost
private fun getPort(): Int {
return prefs[Const.Pref.SERVER_PORT] ?: Const.Default.Server.PORT
}
fun getServerPort(): Int {
return InitializerModule.defaultPort
fun init() {
initialServer = UiModel(getHost(), getPort().toString())
uiModels = MutableStateFlow(initialServer)
}
override fun onCleared() {
super.onCleared()
twig("SettingsViewModel cleared!")
suspend fun resetServer() {
UiModel(
Const.Default.Server.HOST,
Const.Default.Server.PORT.toString()
).let { default ->
uiModels.value = default
submit()
}
}
}
suspend fun submit() {
var error: Throwable? = null
val host = uiModels.value.host
val port = uiModels.value.portInt
synchronizer.changeServer(uiModels.value.host, uiModels.value.portInt) {
error = it
}
if (error == null) {
prefs[Const.Pref.SERVER_HOST] = host
prefs[Const.Pref.SERVER_PORT] = port
}
uiModels.value = uiModels.value.copy(changeError = error, complete = true)
}
private fun onUpdateModel(kProperty: KProperty<*>, old: String, new: String) {
val pendingPort = pendingPortText.toIntOrNull() ?: -1
uiModels.value = UiModel(
pendingHost,
pendingPortText,
pendingHost != initialServer.host || pendingPortText != initialServer.portText,
if (!pendingHost.isValidHost()) "Please enter a valid host name or IP" else null,
if (pendingPort >= 65535) "Please enter a valid port number below 65535" else null
).also {
twig("updated model with $it")
}
}
data class UiModel(
val host: String = "",
val portText: String = "",
val submitEnabled: Boolean = false,
val hostErrorMessage: String? = null,
val portErrorMessage: String? = null,
val changeError: Throwable? = null,
val complete: Boolean = false
) {
val portInt get() = portText.toIntOrNull() ?: -1
val hasError get() = hostErrorMessage != null || portErrorMessage != null
}
// we can beef this up later if we want to but this is enough for now
private fun String.isValidHost(): Boolean {
return !contains("://")
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false" android:color="@color/text_light_dimmed" />
<item android:state_activated="false" android:color="@color/text_light_dimmed"/>
<item android:state_activated="true" android:color="@color/text_light" />
</selector>

View File

@ -1,13 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/background_home">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline_hit_area_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.04" />
<View
android:id="@+id/hit_area_exit"
android:layout_width="68dp"
android:layout_height="68dp"
android:layout_marginStart="24dp"
android:alpha="0.3"
android:background="@android:color/transparent"
android:elevation="6dp"
app:layout_constraintStart_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/guideline_hit_area_top" />
<ImageView
android:id="@+id/icon_profile"
android:id="@+id/icon_exit"
android:layout_width="0dp"
android:layout_height="0dp"
android:elevation="6dp"
@ -15,107 +34,128 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.912"
app:layout_constraintHorizontal_bias="0.088"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.064"
app:layout_constraintVertical_bias="0.065"
app:layout_constraintWidth_percent="0.08"
app:srcCompat="@drawable/ic_cancel" />
<View
android:id="@+id/hit_area_close"
android:layout_width="68dp"
android:layout_height="68dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="16dp"
android:background="@android:color/transparent"
android:elevation="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:id="@+id/text_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="92dp"
style="@style/TextAppearance.MaterialComponents.Headline6"
android:autoSizeTextType="uniform"
android:maxLines="1"
android:text="@string/settings_change_lightwalletd_server"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/text_light"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintBottom_toBottomOf="@id/icon_exit"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/hit_area_exit"
app:layout_constraintTop_toTopOf="@id/icon_exit" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/lightwalletd_server"
android:id="@+id/text_input_layout_host"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="24dp"
android:layout_marginTop="32dp"
android:hint="@string/settings_server_address"
app:errorEnabled="true"
app:helperText="@string/settings_host_helper_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
tools:layout_editor_absoluteX="1dp"
tools:layout_editor_absoluteY="133dp">
app:layout_constraintTop_toBottomOf="@+id/text_title"
app:layout_constraintWidth_percent="0.84">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_text_lightwalletd_server"
android:id="@+id/input_host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/settings_server_address" />
android:background="@android:color/transparent"
android:imeOptions="actionNext"
android:maxLength="253"
android:singleLine="true"
android:textColor="@color/text_light"
android:textColorHint="@color/text_light_dimmed" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/lightwalletd_port"
android:id="@+id/text_input_layout_port"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="24dp"
android:layout_marginTop="18dp"
android:hint="@string/settings_server_port"
app:errorEnabled="true"
app:helperText="@string/settings_port_helper_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/lightwalletd_server"
tools:layout_editor_absoluteX="1dp"
tools:layout_editor_absoluteY="133dp">
app:layout_constraintTop_toBottomOf="@+id/text_input_layout_host"
app:layout_constraintWidth_percent="0.84">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_text_lightwalletd_port"
android:id="@+id/input_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/settings_server_port"
android:background="@android:color/transparent"
android:inputType="number"
android:maxLength="5" />
android:maxLength="5"
android:textColor="@color/text_light"
android:textColorHint="@color/text_light_dimmed" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/button_update"
style="@style/Zcash.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="16dp"
android:text="@string/settings_update"
app:layout_constraintEnd_toStartOf="@+id/button_reset"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/lightwalletd_port" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_reset"
style="@style/Zcash.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="24dp"
android:text="@string/settings_reset_to_default_host"
android:textColor="@color/text_light"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/button_update"
app:layout_constraintTop_toBottomOf="@+id/lightwalletd_port" />
android:layout_marginEnd="16dp"
style="@style/Zcash.Button.OutlinedButton"
android:text="@string/settings_reset"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/selector_secondary_button_activatable"
app:layout_constraintEnd_toStartOf="@id/button_update"
app:layout_constraintTop_toTopOf="@id/button_update"
app:strokeColor="@color/selector_secondary_button_activatable" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_update"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
style="@style/Zcash.Button"
android:backgroundTint="@color/selector_primary_button_activatable"
android:text="@string/settings_update"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toEndOf="@id/text_input_layout_host"
app:layout_constraintTop_toBottomOf="@+id/text_input_layout_port" />
<View
android:id="@+id/loading_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/zcashWhite_24"
android:clickable="true"
android:focusableInTouchMode="true"
android:elevation="8dp" />
<ProgressBar
android:id="@+id/loading_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
app:layout_constraintTop_toTopOf="@id/button_reset"
app:layout_constraintBottom_toBottomOf="@id/button_reset"
app:layout_constraintStart_toStartOf="@id/icon_exit"/>
<androidx.constraintlayout.widget.Group
android:id="@+id/group_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="loading_progress,loading_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -49,11 +49,13 @@
<string name="profile_app_version">v1.0.0-alpha05</string>
<!-- Settings-->
<string name="settings_change_lightwalletd_server">Change lightwalletd server:</string>
<string name="settings_server_address">Server Address</string>
<string name="settings_server_port">Server Port</string>
<string name="settings_change_lightwalletd_server">Change Lightwalletd Server</string>
<string name="settings_server_address">Host</string>
<string name="settings_server_port">Port</string>
<string name="settings_update">Update</string>
<string name="settings_reset_to_default_host">Reset to Default Host</string>
<string name="settings_reset">Reset</string>
<string name="settings_port_helper_text">Enter a valid port number</string>
<string name="settings_host_helper_text">Enter a valid host name or IP address</string>
<!-- Dialogs -->
<string name="dialog_not_again">Don\'t show me again</string>