Skip to content

Commit

Permalink
feat(create): AND-0000 Improve create wallet UX (#4087)
Browse files Browse the repository at this point in the history
  • Loading branch information
aromano-bc committed Nov 14, 2022
1 parent 7d8023e commit 12138bc
Show file tree
Hide file tree
Showing 14 changed files with 282 additions and 126 deletions.
Expand Up @@ -312,6 +312,7 @@ val applicationModule = module {
eligibilityService = get(),
referralService = get(),
payloadDataManager = get(),
nabuUserDataManager = get(),
)
}

Expand Down
@@ -1,94 +1,51 @@
package piuk.blockchain.android.cards

import android.content.Context
import android.os.Bundle
import android.text.Editable
import android.util.DisplayMetrics
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.compose.runtime.Composable
import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.blockchain.commonarch.presentation.base.SlidingModalBottomDialog
import com.blockchain.commonarch.presentation.base.ComposeModalBottomDialog
import com.blockchain.utils.unsafeLazy
import java.io.Serializable
import java.util.Locale
import piuk.blockchain.android.R
import piuk.blockchain.android.databinding.PickerLayoutBinding
import piuk.blockchain.android.util.AfterTextChangedWatcher

class SearchPickerItemBottomSheet : SlidingModalBottomDialog<PickerLayoutBinding>() {
private val searchResults = mutableListOf<PickerItem>()
private val adapter by unsafeLazy {
PickerItemsAdapter {
(parentFragment as? PickerItemListener)?.onItemPicked(it)
?: (activity as? PickerItemListener)?.onItemPicked(it)
?: throw IllegalStateException(
"Host should implement PickerItemListener"
)
dismiss()
}
}
class SearchPickerItemBottomSheet : ComposeModalBottomDialog() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(DialogFragment.STYLE_NORMAL, R.style.FloatingBottomSheet)
}
override val host: PickerItemListener
get() = super.host as PickerItemListener

private val items: List<PickerItem> by unsafeLazy {
(arguments?.getSerializable(PICKER_ITEMS) as? List<PickerItem>) ?: emptyList()
(requireArguments().getSerializable(ARG_PICKER_ITEMS) as? List<PickerItem>) ?: emptyList()
}

override fun initBinding(inflater: LayoutInflater, container: ViewGroup?): PickerLayoutBinding =
PickerLayoutBinding.inflate(inflater, container, false)

override fun initControls(binding: PickerLayoutBinding) {
with(binding) {
countryCodePickerSearch.addTextChangedListener(object : AfterTextChangedWatcher() {
override fun afterTextChanged(searchQuery: Editable) {
search(searchQuery.toString())
}
})

val layoutManager = LinearLayoutManager(activity)

pickerRecyclerView.layoutManager = layoutManager
pickerRecyclerView.adapter = adapter
adapter.items = items

countryCodePickerSearch.setOnEditorActionListener { _, _, _ ->
val imm: InputMethodManager = countryCodePickerSearch.context
.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(countryCodePickerSearch.windowToken, 0)
true
}
configureRootViewMinHeight()
}
private val suggestedPick: PickerItem? by unsafeLazy {
requireArguments().getSerializable(ARG_SUGGESTED_PICK) as? PickerItem
}

private fun configureRootViewMinHeight() {
val displayMetrics = DisplayMetrics()
activity?.windowManager?.defaultDisplay?.getMetrics(displayMetrics)?.let {
binding.rootView.minimumHeight = (displayMetrics.heightPixels * 0.6).toInt()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(DialogFragment.STYLE_NORMAL, R.style.FloatingBottomSheet)
}

private fun search(searchQuery: String) {
searchResults.clear()
for (item in items) {
if (item.label.lowercase(Locale.getDefault()).contains(searchQuery.lowercase(Locale.getDefault()))) {
searchResults.add(item)
}
}
adapter.items = searchResults
@Composable
override fun Sheet() {
SearchPickerItemScreen(
suggestedPick = suggestedPick,
items = items,
onItemClicked = {
host.onItemPicked(it)
dismiss()
},
)
}

companion object {
private const val PICKER_ITEMS = "PICKER_ITEMS"
fun newInstance(items: List<PickerItem>): SearchPickerItemBottomSheet =
private const val ARG_PICKER_ITEMS = "ARG_PICKER_ITEMS"
private const val ARG_SUGGESTED_PICK = "ARG_SUGGESTED_PICK"
fun newInstance(items: List<PickerItem>, suggestedPick: PickerItem? = null): SearchPickerItemBottomSheet =
SearchPickerItemBottomSheet().apply {
arguments = Bundle().also {
it.putSerializable(PICKER_ITEMS, items as Serializable)
it.putSerializable(ARG_PICKER_ITEMS, items as Serializable)
it.putSerializable(ARG_SUGGESTED_PICK, suggestedPick)
}
}
}
Expand All @@ -100,6 +57,6 @@ interface PickerItem : Serializable {
val icon: String?
}

interface PickerItemListener {
interface PickerItemListener : ComposeModalBottomDialog.Host {
fun onItemPicked(item: PickerItem)
}
@@ -0,0 +1,144 @@
package piuk.blockchain.android.cards

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.blockchain.componentlib.basic.ComposeColors
import com.blockchain.componentlib.basic.ComposeGravities
import com.blockchain.componentlib.basic.ComposeTypographies
import com.blockchain.componentlib.basic.ImageResource
import com.blockchain.componentlib.basic.SimpleText
import com.blockchain.componentlib.controls.OutlinedTextInput
import com.blockchain.componentlib.divider.HorizontalDivider
import com.blockchain.componentlib.theme.AppTheme
import piuk.blockchain.android.R

@Composable
fun SearchPickerItemScreen(
suggestedPick: PickerItem?,
items: List<PickerItem>,
onItemClicked: (PickerItem) -> Unit,
) {
var searchInput by remember { mutableStateOf("") }
val filteredItems: List<PickerItem> = remember(searchInput) {
items.filter { item ->
item.label.contains(searchInput, ignoreCase = true)
}
}

Column(Modifier.fillMaxHeight()) {
val searchInputIcon =
if (searchInput.isNotEmpty()) ImageResource.Local(R.drawable.ic_close_circle)
else ImageResource.None
OutlinedTextInput(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = AppTheme.dimensions.smallSpacing),
value = searchInput,
onValueChange = { searchInput = it },
singleLine = true,
placeholder = stringResource(R.string.common_search),
leadingIcon = ImageResource.Local(R.drawable.ic_search),
focusedTrailingIcon = searchInputIcon,
unfocusedTrailingIcon = searchInputIcon,
onTrailingIconClicked = { searchInput = "" },
)

LazyColumn() {
if (suggestedPick != null && searchInput.isEmpty()) {
item {
SimpleText(
modifier = Modifier.padding(start = AppTheme.dimensions.mediumSpacing),
text = stringResource(R.string.country_selection_suggested),
style = ComposeTypographies.Caption1,
color = ComposeColors.Title,
gravity = ComposeGravities.Start,
)

PickerItemContent(suggestedPick, onItemClicked)

HorizontalDivider(
Modifier
.fillMaxWidth()
.padding(bottom = AppTheme.dimensions.tinySpacing)
)
}
}

items(filteredItems) {
PickerItemContent(it, onItemClicked)
}
}
}
}

@Composable
private fun PickerItemContent(item: PickerItem, onClick: (PickerItem) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(item) }
.padding(horizontal = AppTheme.dimensions.mediumSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
item.icon?.let { icon ->
Text(text = icon, fontSize = 30.sp)
}
Text(
modifier = Modifier.padding(AppTheme.dimensions.smallSpacing),
text = item.label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}

@Preview
@Composable
private fun PreviewScreen() {
val suggestedPick = object : PickerItem {
override val label: String = "Portugal"
override val code: String = "PT"
override val icon: String? = "🇵🇹"
}
val items = (0..20).map {
object : PickerItem {
override val label: String = "United Kingdom"
override val code: String = "UK $it"
override val icon: String? = "🇬🇧"
}
}
SearchPickerItemScreen(
suggestedPick = suggestedPick,
items = items,
onItemClicked = {},
)
}

@Preview
@Composable
private fun PreviewPickerItemContent() {
val item = object : PickerItem {
override val label: String = "United Kingdom"
override val code: String = "UK"
override val icon: String? = "🇬🇧"
}
PickerItemContent(item, {})
}
Expand Up @@ -72,7 +72,8 @@ class CreateWalletActivity :
SearchPickerItemBottomSheet.newInstance(
it.countries.map { country ->
CountryPickerItem(country.countryCode)
}
},
it.suggested?.let { CountryPickerItem(it.countryCode) },
)
)
},
Expand Down
Expand Up @@ -410,15 +410,19 @@ private fun EmailAndPasswordStep(
)
}

val checkboxTopPadding =
if (isPasswordStrengthVisible) AppTheme.dimensions.smallSpacing
else AppTheme.dimensions.standardSpacing
Row(
Modifier
.fillMaxWidth()
.padding(top = AppTheme.dimensions.standardSpacing),
.padding(top = checkboxTopPadding, bottom = AppTheme.dimensions.verySmallSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
state = if (state.areTermsOfServiceChecked) CheckboxState.Checked else CheckboxState.Unchecked,
onCheckChanged = { isChecked ->
keyboardController?.hide()
onIntent(CreateWalletIntent.TermsOfServiceStateChanged(isChecked))
}
)
Expand Down Expand Up @@ -460,7 +464,7 @@ private fun Preview_RegionAndReferral() {
isShowingInvalidEmailError = true,
passwordInput = "Somepassword",
passwordInputError = CreateWalletPasswordError.InvalidPasswordTooShort,
countryInputState = CountryInputState.Loaded(countries = countries, selected = countries[1]),
countryInputState = CountryInputState.Loaded(countries = countries, selected = countries[1], suggested = null),
stateInputState = StateInputState.Loading,
areTermsOfServiceChecked = false,
referralCodeInput = "12345678",
Expand All @@ -486,7 +490,7 @@ private fun Preview_EmailAndPassword() {
isShowingInvalidEmailError = true,
passwordInput = "Somepassword",
passwordInputError = CreateWalletPasswordError.InvalidPasswordTooShort,
countryInputState = CountryInputState.Loaded(countries = countries, selected = countries[1]),
countryInputState = CountryInputState.Loaded(countries = countries, selected = countries[1], suggested = null),
stateInputState = StateInputState.Loading,
areTermsOfServiceChecked = false,
referralCodeInput = "12345678",
Expand Down
Expand Up @@ -10,13 +10,15 @@ import com.blockchain.commonarch.presentation.mvi_v2.MviViewModel
import com.blockchain.commonarch.presentation.mvi_v2.NavigationEvent
import com.blockchain.componentlib.button.ButtonState
import com.blockchain.core.payload.PayloadDataManager
import com.blockchain.core.user.NabuUserDataManager
import com.blockchain.domain.eligibility.EligibilityService
import com.blockchain.domain.eligibility.model.GetRegionScope
import com.blockchain.domain.referral.ReferralService
import com.blockchain.enviroment.EnvironmentConfig
import com.blockchain.outcome.Outcome
import com.blockchain.outcome.doOnFailure
import com.blockchain.outcome.doOnSuccess
import com.blockchain.outcome.getOrDefault
import com.blockchain.preferences.AuthPrefs
import com.blockchain.preferences.WalletStatusPrefs
import com.blockchain.utils.awaitOutcome
Expand All @@ -25,6 +27,7 @@ import com.google.android.gms.recaptcha.RecaptchaActionType
import info.blockchain.wallet.util.PasswordUtil
import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import piuk.blockchain.android.ui.referral.presentation.ReferralAnalyticsEvents
import piuk.blockchain.android.util.AppUtil
Expand Down Expand Up @@ -107,6 +110,7 @@ class CreateWalletViewModel(
private val eligibilityService: EligibilityService,
private val referralService: ReferralService,
private val payloadDataManager: PayloadDataManager,
private val nabuUserDataManager: NabuUserDataManager,
) : MviViewModel<
CreateWalletIntent,
CreateWalletViewState,
Expand All @@ -119,18 +123,31 @@ class CreateWalletViewModel(

override fun viewCreated(args: ModelConfigArgs.NoArgs) {
viewModelScope.launch {
eligibilityService.getCountriesList(GetRegionScope.Signup)
val userGeolocationDeferred = async { nabuUserDataManager.getUserGeolocation().getOrDefault(null) }
val countriesResult = eligibilityService.getCountriesList(GetRegionScope.Signup)
val userGeolocation = userGeolocationDeferred.await()

countriesResult
.doOnSuccess { countries ->
val localisedCountries = countries.map {
val locale = Locale("", it.countryCode)
it.copy(name = locale.displayCountry)
}
updateState { it.copy(countryInputState = CountryInputState.Loaded(localisedCountries, null)) }
val suggested = localisedCountries.find { it.countryCode == userGeolocation }
updateState {
it.copy(
countryInputState = CountryInputState.Loaded(
countries = localisedCountries,
selected = null,
suggested = suggested,
)
)
}
}
.doOnFailure { error ->
updateState {
it.copy(
countryInputState = CountryInputState.Loaded(emptyList(), null),
countryInputState = CountryInputState.Loaded(emptyList(), null, null),
error = CreateWalletError.Unknown(error.message)
)
}
Expand Down

0 comments on commit 12138bc

Please sign in to comment.