From 2ad04b1e8886e582e89cefdb965069ac22b7b387 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Fri, 31 Jan 2025 18:56:24 -0500 Subject: [PATCH] Add new country picker for registration. Co-authored-by: Greyson Parrelli --- .../registration/ui/RegistrationState.kt | 4 +- .../registration/ui/RegistrationViewModel.kt | 13 + .../registration/ui/countrycode/Country.kt | 11 + .../ui/countrycode/CountryCodeFragment.kt | 297 +++++++++++++++++ .../ui/countrycode/CountryCodeState.kt | 16 + .../ui/countrycode/CountryCodeViewModel.kt | 97 ++++++ .../ui/countrycode/CountryUtils.kt | 29 ++ .../phonenumber/EnterPhoneNumberFragment.kt | 55 ++-- .../ui/phonenumber/EnterPhoneNumberState.kt | 4 +- .../phonenumber/EnterPhoneNumberViewModel.kt | 10 +- .../registrationv3/ui/RegistrationState.kt | 4 +- .../ui/RegistrationViewModel.kt | 13 + .../registrationv3/ui/countrycode/Country.kt | 11 + .../ui/countrycode/CountryCodeFragment.kt | 298 ++++++++++++++++++ .../ui/countrycode/CountryCodeState.kt | 16 + .../ui/countrycode/CountryCodeViewModel.kt | 98 ++++++ .../phonenumber/EnterPhoneNumberFragment.kt | 67 ++-- .../ui/phonenumber/EnterPhoneNumberState.kt | 4 +- .../phonenumber/EnterPhoneNumberViewModel.kt | 14 +- .../drawable/country_picker_background.xml | 19 ++ ...agment_registration_enter_phone_number.xml | 82 ++++- app/src/main/res/navigation/registration.xml | 15 +- .../main/res/navigation/registration_v3.xml | 14 + app/src/main/res/values/strings.xml | 13 + 24 files changed, 1124 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/Country.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryUtils.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/Country.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeViewModel.kt create mode 100644 app/src/main/res/drawable/country_picker_background.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationState.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationState.kt index 45af3ceb2f..1025319f9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationState.kt @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.registration.data.network.Challenge import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.ui.countrycode.Country import org.whispersystems.signalservice.api.svr.Svr3Credentials import org.whispersystems.signalservice.internal.push.AuthCredentials import kotlin.time.Duration @@ -52,7 +53,8 @@ data class RegistrationState( val networkError: Throwable? = null, val sessionCreationError: RegistrationSessionResult? = null, val sessionStateError: VerificationCodeRequestResult? = null, - val registerAccountError: RegisterAccountResult? = null + val registerAccountError: RegisterAccountResult? = null, + val country: Country? = null ) { val challengesRemaining: List = challengesRequested.filterNot { it in challengesPresented } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt index ba657a4428..26fa11fcd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt @@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequ import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.Success import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.TokenNotAccepted import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.UnknownError +import org.thoughtcrime.securesms.registration.ui.countrycode.Country import org.thoughtcrime.securesms.registration.util.RegistrationUtil import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet import org.thoughtcrime.securesms.util.RemoteConfig @@ -194,6 +195,18 @@ class RegistrationViewModel : ViewModel() { } } + fun setCurrentCountryPicked(country: Country) { + store.update { + it.copy(country = country) + } + } + + fun clearCountry() { + store.update { + it.copy(country = null) + } + } + fun fetchFcmToken(context: Context) { viewModelScope.launch(context = coroutineExceptionHandler) { val fcmToken = RegistrationRepository.getFcmToken(context) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/Country.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/Country.kt new file mode 100644 index 0000000000..15148e0d21 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/Country.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.ui.countrycode + +/** + * Data class string describing useful characteristics of countries when selecting one. Used in the [CountryCodeState] + */ +data class Country(val emoji: String, val name: String, val countryCode: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeFragment.kt new file mode 100644 index 0000000000..41920dffdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeFragment.kt @@ -0,0 +1,297 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.thoughtcrime.securesms.registration.ui.countrycode + +import android.os.Bundle +import android.view.View +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +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.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController +import org.signal.core.ui.Dividers +import org.signal.core.ui.Previews +import org.signal.core.ui.Scaffolds +import org.signal.core.ui.SignalPreview +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel + +/** + * Country picker fragment used in registration V1 + */ +class CountryCodeFragment : ComposeFragment() { + + companion object { + private val TAG = Log.tag(CountryCodeFragment::class.java) + } + + private val viewModel: CountryCodeViewModel by viewModels() + private val sharedViewModel by activityViewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + Screen( + state = state, + onSearch = { search -> viewModel.filterCountries(search) }, + onDismissed = { findNavController().popBackStack() }, + onClick = { country -> + sharedViewModel.setCurrentCountryPicked(country) + findNavController().popBackStack() + } + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.loadCountries() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Screen( + state: CountryCodeState, + onSearch: (String) -> Unit = {}, + onDismissed: () -> Unit = {}, + onClick: (Country) -> Unit = {} +) { + Scaffold( + topBar = { + Scaffolds.DefaultTopAppBar( + title = stringResource(R.string.CountryCodeFragment__your_country), + titleContent = { _, title -> + Text(text = title, style = MaterialTheme.typography.titleLarge) + }, + onNavigationClick = onDismissed, + navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.symbol_x_24)) + ) + } + ) { padding -> + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(padding) + ) { + item { + SearchBar( + text = state.query, + onSearch = onSearch + ) + Spacer(modifier = Modifier.size(18.dp)) + } + + if (state.countryList.isEmpty()) { + item { + CircularProgressIndicator( + modifier = Modifier.size(56.dp) + ) + } + } else if (state.query.isEmpty()) { + items(state.commonCountryList) { country -> + CountryItem(country, onClick) + } + + item { + Dividers.Default() + } + + items(state.countryList) { country -> + CountryItem(country, onClick) + } + } else { + items(state.filteredList) { country -> + CountryItem(country, onClick, state.query) + } + } + } + } +} + +@Composable +fun CountryItem( + country: Country, + onClick: (Country) -> Unit = {}, + query: String = "" +) { + val emoji = country.emoji + val name = country.name + val code = country.countryCode + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) + .clickable { onClick(country) } + ) { + Text( + text = emoji, + modifier = Modifier.size(24.dp) + ) + + if (query.isEmpty()) { + Text( + text = name.ifEmpty { stringResource(R.string.CountryCodeFragment__unknown_country) }, + modifier = Modifier + .padding(start = 24.dp) + .weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = code, + modifier = Modifier.padding(start = 24.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val annotatedName = buildAnnotatedString { + val startIndex = name.indexOf(query, ignoreCase = true) + + if (startIndex >= 0) { + append(name.substring(0, startIndex)) + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(name.substring(startIndex, startIndex + query.length)) + } + + append(name.substring(startIndex + query.length)) + } else { + append(name) + } + } + + val annotatedCode = buildAnnotatedString { + val startIndex = code.indexOf(query, ignoreCase = true) + + if (startIndex >= 0) { + append(code.substring(0, startIndex)) + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(code.substring(startIndex, startIndex + query.length)) + } + + append(code.substring(startIndex + query.length)) + } else { + append(code) + } + } + + Text( + text = annotatedName, + modifier = Modifier + .padding(start = 24.dp) + .weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = annotatedCode, + modifier = Modifier.padding(start = 24.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun SearchBar( + text: String, + modifier: Modifier = Modifier, + hint: String = stringResource(R.string.CountryCodeFragment__search_by), + onSearch: (String) -> Unit = {} +) { + TextField( + value = text, + onValueChange = { onSearch(it) }, + placeholder = { Text(hint) }, + // TODO(michelle): Add keyboard switch to dialpad +// trailingIcon = { +// Icon( +// imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24), +// contentDescription = "Search icon" +// ) +// }, + shape = RoundedCornerShape(32.dp), + modifier = modifier + .fillMaxWidth() + .height(54.dp) + .padding(horizontal = 16.dp), + visualTransformation = VisualTransformation.None, + colors = TextFieldDefaults.colors( + // TODO move to SignalTheme + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) +} + +@SignalPreview +@Composable +private fun ScreenPreview() { + Previews.Preview { + Screen( + state = CountryCodeState( + countryList = mutableListOf( + Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+1"), + Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+2"), + Country("\uD83C\uDDF2\uD83C\uDDFD", "Mexico", "+3") + ), + commonCountryList = mutableListOf( + Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+4"), + Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+5") + ) + ) + ) + } +} + +@SignalPreview +@Composable +private fun LoadingScreenPreview() { + Previews.Preview { + Screen( + state = CountryCodeState( + countryList = emptyList() + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeState.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeState.kt new file mode 100644 index 0000000000..b2a657a1c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeState.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.ui.countrycode + +/** + * State managed by [CountryCodeViewModel]. Includes country list and allows for searching + */ +data class CountryCodeState( + val query: String = "", + val countryList: List = emptyList(), + val commonCountryList: List = emptyList(), + val filteredList: List = emptyList() +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeViewModel.kt new file mode 100644 index 0000000000..7d3677bc69 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeViewModel.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.ui.countrycode + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.i18n.phonenumbers.PhoneNumberUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter +import java.util.Locale + +/** + * View model to support [CountryCodeFragment] and track the countries + */ +class CountryCodeViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(CountryCodeViewModel::class.java) + } + + private val internalState = MutableStateFlow(CountryCodeState()) + val state = internalState.asStateFlow() + + fun filterCountries(filterBy: String) { + if (filterBy.isEmpty()) { + internalState.update { + it.copy( + query = filterBy, + filteredList = emptyList() + ) + } + } else { + internalState.update { + it.copy( + query = filterBy, + filteredList = state.value.countryList.filter { country: Country -> + country.name.contains(filterBy, ignoreCase = true) || country.countryCode.contains(filterBy) + } + ) + } + } + } + + fun loadCountries() { + loadCommonCountryList() + viewModelScope.launch(Dispatchers.IO) { + val regions = PhoneNumberUtil.getInstance().supportedRegions + val countries = mutableListOf() + + for (region in regions) { + val c = Country( + name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""), + emoji = CountryUtils.countryToEmoji(region), + countryCode = "+" + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region) + ) + countries.add(c) + } + + val sortedCountries = countries.sortedWith { lhs, rhs -> + lhs.name.lowercase(Locale.getDefault()).compareTo(rhs.name.lowercase(Locale.getDefault())) + } + + internalState.update { + it.copy( + countryList = sortedCountries + ) + } + } + } + + private fun loadCommonCountryList() { + viewModelScope.launch(Dispatchers.IO) { + val countries = mutableListOf() + for (region in CountryUtils.COMMON_COUNTRIES) { + val c = Country( + name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""), + emoji = CountryUtils.countryToEmoji(region), + countryCode = "+" + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region) + ) + countries.add(c) + } + internalState.update { + it.copy( + commonCountryList = countries + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryUtils.kt new file mode 100644 index 0000000000..ab155804f4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryUtils.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.ui.countrycode + +import java.util.Locale + +/** + * Utility functions used when working with countries + */ +object CountryUtils { + + fun countryToEmoji(countryCode: String): String { + return if (countryCode.isNotEmpty()) { + countryCode + .uppercase(Locale.US) + .map { char -> Character.codePointAt("$char", 0) - 0x41 + 0x1F1E6 } + .map { codePoint -> Character.toChars(codePoint) } + .joinToString(separator = "") { charArray -> String(charArray) } + } else { + "" + } + } + + /** A hardcoded list of countries to suggest during registration. Can change at any time. */ + val COMMON_COUNTRIES = listOf("US", "DE", "IN", "NL", "UA") +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt index 358854661d..4b3b500d3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt @@ -31,7 +31,6 @@ import androidx.navigation.fragment.findNavController import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.textfield.TextInputEditText import com.google.i18n.phonenumbers.AsYouTypeFormatter import com.google.i18n.phonenumbers.NumberParseException @@ -58,6 +57,7 @@ import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegat import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint import org.thoughtcrime.securesms.registration.ui.RegistrationState import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registration.ui.countrycode.Country import org.thoughtcrime.securesms.registration.ui.toE164 import org.thoughtcrime.securesms.registration.util.CountryPrefix import org.thoughtcrime.securesms.util.CommunicationActions @@ -85,7 +85,8 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ private lateinit var spinnerAdapter: ArrayAdapter private lateinit var phoneNumberInputLayout: TextInputEditText - private lateinit var spinnerView: MaterialAutoCompleteTextView + private lateinit var spinnerView: TextInputEditText + private lateinit var countryPickerView: View private var currentPhoneNumberFormatter: AsYouTypeFormatter? = null @@ -101,7 +102,13 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ } ) phoneNumberInputLayout = binding.number.editText as TextInputEditText - spinnerView = binding.countryCode.editText as MaterialAutoCompleteTextView + spinnerView = binding.countryCode.editText as TextInputEditText + countryPickerView = binding.countryPicker + + countryPickerView.setOnClickListener { + moveToCountryPickerScreen() + } + spinnerAdapter = ArrayAdapter( requireContext(), R.layout.registration_country_code_dropdown_item, @@ -118,6 +125,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState -> presentRegisterButton(sharedState) updateEnabledControls(sharedState.inProgress, sharedState.isReRegister) + updateCountrySelection(sharedState.country) sharedState.networkError?.let { presentNetworkError(it) @@ -167,6 +175,8 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ sharedViewModel.setPhoneNumber(null) } + updateCountrySelection(fragmentState.country) + if (fragmentState.error != EnterPhoneNumberState.Error.NONE) { presentLocalError(fragmentState) } @@ -186,6 +196,15 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout) } + private fun updateCountrySelection(country: Country?) { + if (country != null) { + binding.countryEmoji.text = country.emoji + binding.country.text = country.name + binding.countryCode.editText?.setText(country.countryCode) + } + sharedViewModel.clearCountry() + } + private fun reformatText(text: Editable?) { if (text.isNullOrEmpty()) { return @@ -220,6 +239,12 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ private fun initializeInputFields() { binding.countryCode.editText?.addTextChangedListener { s -> + if (s.isNotNullOrBlank()) { + val formatted = s.toString().replace("+", "").let { "+$it" } + if (formatted != s.toString()) { + s.replace(0, s.length, formatted) + } + } val sanitized = s.toString().filter { c -> c.isDigit() } if (sanitized.isNotNullOrBlank()) { val countryCode: Int = sanitized.toInt() @@ -252,26 +277,6 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ } false } - - spinnerView.threshold = 100 - spinnerView.setAdapter(spinnerAdapter) - spinnerView.addTextChangedListener(afterTextChanged = ::onCountryDropDownChanged) - } - - private fun onCountryDropDownChanged(s: Editable?) { - if (s.isNullOrEmpty()) { - return - } - - if (s[0] != '+') { - s.insert(0, "+") - } - - fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let { - fragmentViewModel.setCountry(it.digits) - val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0 - phoneNumberInputLayout.setSelection(numberLength, numberLength) - } } private fun presentRegisterButton(sharedState: RegistrationState) { @@ -648,6 +653,10 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ sharedViewModel.setInProgress(false) } + private fun moveToCountryPickerScreen() { + findNavController().safeNavigate(R.id.action_countryPicker) + } + private fun popBackStack() { sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION) findNavController().popBackStack() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberState.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberState.kt index b037311133..275295f473 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberState.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.registration.ui.phonenumber import org.thoughtcrime.securesms.registration.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.ui.countrycode.Country /** * State holder for the phone number entry screen, including phone number and Play Services errors. @@ -15,7 +16,8 @@ data class EnterPhoneNumberState( val phoneNumber: String = "", val phoneNumberRegionCode: String, val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER, - val error: Error = Error.NONE + val error: Error = Error.NONE, + val country: Country? = null ) { enum class Error { NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberViewModel.kt index e9c5f7c061..5df9dc986c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberViewModel.kt @@ -14,7 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.registration.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.ui.countrycode.Country +import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils import org.thoughtcrime.securesms.registration.util.CountryPrefix +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter /** * ViewModel for the phone number entry screen. @@ -69,7 +72,12 @@ class EnterPhoneNumberViewModel : ViewModel() { store.update { it.copy( countryPrefixIndex = matchingIndex, - phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode + phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode, + country = Country( + name = PhoneNumberFormatter.getRegionDisplayName(supportedCountryPrefixes[matchingIndex].regionCode).orElse(""), + emoji = CountryUtils.countryToEmoji(supportedCountryPrefixes[matchingIndex].regionCode), + countryCode = digits.toString() + ) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt index 7853313fef..d96a6a2d2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.registration.data.network.Challenge import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country import org.whispersystems.signalservice.api.svr.Svr3Credentials import org.whispersystems.signalservice.internal.push.AuthCredentials import kotlin.time.Duration @@ -53,7 +54,8 @@ data class RegistrationState( val networkError: Throwable? = null, val sessionCreationError: RegistrationSessionResult? = null, val sessionStateError: VerificationCodeRequestResult? = null, - val registerAccountError: RegisterAccountResult? = null + val registerAccountError: RegisterAccountResult? = null, + val country: Country? = null ) { val challengesRemaining: List = challengesRequested.filterNot { it in challengesPresented } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt index d00a813ea0..328611e130 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt @@ -67,6 +67,7 @@ import org.thoughtcrime.securesms.registration.ui.toE164 import org.thoughtcrime.securesms.registration.util.RegistrationUtil import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository +import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.dualsim.MccMncProducer @@ -987,6 +988,18 @@ class RegistrationViewModel : ViewModel() { .firstOrNull() } + fun setCurrentCountryPicked(country: Country) { + store.update { + it.copy(country = country) + } + } + + fun clearCountry() { + store.update { + it.copy(country = null) + } + } + companion object { private val TAG = Log.tag(RegistrationViewModel::class.java) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/Country.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/Country.kt new file mode 100644 index 0000000000..f1f1e8e210 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/Country.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.countrycode + +/** + * Data class string describing useful characteristics of countries when selecting one. Used in the [CountryCodeState] + */ +data class Country(val emoji: String, val name: String, val countryCode: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeFragment.kt new file mode 100644 index 0000000000..48730d3323 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeFragment.kt @@ -0,0 +1,298 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.thoughtcrime.securesms.registrationv3.ui.countrycode + +import android.os.Bundle +import android.view.View +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +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.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController +import org.signal.core.ui.Dividers +import org.signal.core.ui.Previews +import org.signal.core.ui.Scaffolds +import org.signal.core.ui.SignalPreview +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel + +/** + * Country picker fragment used in registration V3 + */ +class CountryCodeFragment : ComposeFragment() { + + companion object { + private val TAG = Log.tag(CountryCodeFragment::class.java) + } + + private val viewModel: CountryCodeViewModel by viewModels() + private val sharedViewModel by activityViewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + Screen( + state = state, + onSearch = { search -> viewModel.filterCountries(search) }, + onDismissed = { findNavController().popBackStack() }, + onClick = { country -> + sharedViewModel.setCurrentCountryPicked(country) + findNavController().popBackStack() + } + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.loadCountries() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Screen( + state: CountryCodeState, + onSearch: (String) -> Unit = {}, + onDismissed: () -> Unit = {}, + onClick: (Country) -> Unit = {} +) { + Scaffold( + topBar = { + Scaffolds.DefaultTopAppBar( + title = stringResource(R.string.CountryCodeFragment__your_country), + titleContent = { _, title -> + Text(text = title, style = MaterialTheme.typography.titleLarge) + }, + onNavigationClick = onDismissed, + navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.symbol_x_24)) + ) + } + ) { padding -> + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(padding) + ) { + item { + SearchBar( + text = state.query, + onSearch = onSearch + ) + Spacer(modifier = Modifier.size(18.dp)) + } + + if (state.countryList.isEmpty()) { + item { + CircularProgressIndicator( + modifier = Modifier.size(56.dp) + ) + } + } else if (state.query.isEmpty()) { + items(state.commonCountryList) { country -> + CountryItem(country, onClick) + } + + item { + Dividers.Default() + } + + items(state.countryList) { country -> + CountryItem(country, onClick) + } + } else { + items(state.filteredList) { country -> + CountryItem(country, onClick, state.query) + } + } + } + } +} + +@Composable +fun CountryItem( + country: Country, + onClick: (Country) -> Unit = {}, + query: String = "" +) { + val emoji = country.emoji + val name = country.name + val code = country.countryCode + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) + .clickable { onClick(country) } + ) { + Text( + text = emoji, + modifier = Modifier.size(24.dp) + ) + + if (query.isEmpty()) { + Text( + text = name.ifEmpty { stringResource(R.string.CountryCodeFragment__unknown_country) }, + modifier = Modifier + .padding(start = 24.dp) + .weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = code, + modifier = Modifier.padding(start = 24.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + val annotatedName = buildAnnotatedString { + val startIndex = name.indexOf(query, ignoreCase = true) + + if (startIndex >= 0) { + append(name.substring(0, startIndex)) + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(name.substring(startIndex, startIndex + query.length)) + } + + append(name.substring(startIndex + query.length)) + } else { + append(name) + } + } + + val annotatedCode = buildAnnotatedString { + val startIndex = code.indexOf(query, ignoreCase = true) + + if (startIndex >= 0) { + append(code.substring(0, startIndex)) + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(code.substring(startIndex, startIndex + query.length)) + } + + append(code.substring(startIndex + query.length)) + } else { + append(code) + } + } + + Text( + text = annotatedName, + modifier = Modifier + .padding(start = 24.dp) + .weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = annotatedCode, + modifier = Modifier.padding(start = 24.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun SearchBar( + text: String, + modifier: Modifier = Modifier, + hint: String = stringResource(R.string.CountryCodeFragment__search_by), + onSearch: (String) -> Unit = {} +) { + TextField( + value = text, + onValueChange = { onSearch(it) }, + placeholder = { Text(hint) }, + trailingIcon = { + // TODO(michelle): Add keyboard switch to dialpad + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24), + contentDescription = "Search icon" + ) + }, + shape = RoundedCornerShape(32.dp), + modifier = modifier + .fillMaxWidth() + .height(54.dp) + .padding(horizontal = 16.dp), + visualTransformation = VisualTransformation.None, + colors = TextFieldDefaults.colors( + // TODO move to SignalTheme + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) +} + +@SignalPreview +@Composable +private fun ScreenPreview() { + Previews.Preview { + Screen( + state = CountryCodeState( + countryList = mutableListOf( + Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+1"), + Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+2"), + Country("\uD83C\uDDF2\uD83C\uDDFD", "Mexico", "+3") + ), + commonCountryList = mutableListOf( + Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+4"), + Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+5") + ) + ) + ) + } +} + +@SignalPreview +@Composable +private fun LoadingScreenPreview() { + Previews.Preview { + Screen( + state = CountryCodeState( + countryList = emptyList() + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeState.kt new file mode 100644 index 0000000000..cdbf5be240 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeState.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.countrycode + +/** + * State managed by [CountryCodeViewModel]. Includes country list and allows for searching + */ +data class CountryCodeState( + val query: String = "", + val countryList: List = emptyList(), + val commonCountryList: List = emptyList(), + val filteredList: List = emptyList() +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeViewModel.kt new file mode 100644 index 0000000000..00f657268b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeViewModel.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.countrycode + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.i18n.phonenumbers.PhoneNumberUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter +import java.util.Locale + +/** + * View model to support [CountryCodeFragment] and track the countries + */ +class CountryCodeViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(CountryCodeViewModel::class.java) + } + + private val internalState = MutableStateFlow(CountryCodeState()) + val state = internalState.asStateFlow() + + fun filterCountries(filterBy: String) { + if (filterBy.isEmpty()) { + internalState.update { + it.copy( + query = filterBy, + filteredList = emptyList() + ) + } + } else { + internalState.update { + it.copy( + query = filterBy, + filteredList = state.value.countryList.filter { country: Country -> + country.name.contains(filterBy, ignoreCase = true) || country.countryCode.contains(filterBy) + } + ) + } + } + } + + fun loadCountries() { + loadCommonCountryList() + viewModelScope.launch(Dispatchers.IO) { + val regions = PhoneNumberUtil.getInstance().supportedRegions + val countries = mutableListOf() + + for (region in regions) { + val c = Country( + name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""), + emoji = CountryUtils.countryToEmoji(region), + countryCode = "+" + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region) + ) + countries.add(c) + } + + val sortedCountries = countries.sortedWith { lhs, rhs -> + lhs.name.lowercase(Locale.getDefault()).compareTo(rhs.name.lowercase(Locale.getDefault())) + } + + internalState.update { + it.copy( + countryList = sortedCountries + ) + } + } + } + + private fun loadCommonCountryList() { + viewModelScope.launch(Dispatchers.IO) { + val countries = mutableListOf() + for (region in CountryUtils.COMMON_COUNTRIES) { + val c = Country( + name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""), + emoji = CountryUtils.countryToEmoji(region), + countryCode = "+" + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region) + ) + countries.add(c) + } + internalState.update { + it.copy( + commonCountryList = countries + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt index a1a4db21c5..650aa02c56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt @@ -32,7 +32,6 @@ import androidx.navigation.fragment.navArgs import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.textfield.TextInputEditText import com.google.i18n.phonenumbers.AsYouTypeFormatter import com.google.i18n.phonenumbers.NumberParseException @@ -61,6 +60,7 @@ import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.Dialogs import org.thoughtcrime.securesms.util.PlayServicesUtil @@ -90,7 +90,8 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ private lateinit var spinnerAdapter: ArrayAdapter private lateinit var phoneNumberInputLayout: TextInputEditText - private lateinit var spinnerView: MaterialAutoCompleteTextView + private lateinit var spinnerView: TextInputEditText + private lateinit var countryPickerView: View private var currentPhoneNumberFormatter: AsYouTypeFormatter? = null @@ -106,7 +107,13 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ } ) phoneNumberInputLayout = binding.number.editText as TextInputEditText - spinnerView = binding.countryCode.editText as MaterialAutoCompleteTextView + spinnerView = binding.countryCode.editText as TextInputEditText + countryPickerView = binding.countryPicker + + countryPickerView.setOnClickListener { + moveToCountryPickerScreen() + } + spinnerAdapter = ArrayAdapter( requireContext(), R.layout.registration_country_code_dropdown_item, @@ -123,6 +130,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState -> presentRegisterButton(sharedState) updateEnabledControls(sharedState.inProgress, sharedState.isReRegister) + updateCountrySelection(sharedState.country) sharedState.networkError?.let { presentNetworkError(it) @@ -172,6 +180,8 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ sharedViewModel.setPhoneNumber(null) } + updateCountrySelection(fragmentState.country) + if (fragmentState.error != EnterPhoneNumberState.Error.NONE) { presentLocalError(fragmentState) } @@ -196,6 +206,15 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ } } + private fun updateCountrySelection(country: Country?) { + if (country != null) { + binding.countryEmoji.text = country.emoji + binding.country.text = country.name + binding.countryCode.editText?.setText(country.countryCode) + } + sharedViewModel.clearCountry() + } + private fun reformatText(text: Editable?) { if (text.isNullOrEmpty()) { return @@ -230,6 +249,12 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ private fun initializeInputFields() { binding.countryCode.editText?.addTextChangedListener { s -> + if (s.isNotNullOrBlank()) { + val formatted = s.toString().replace("+", "").let { "+$it" } + if (formatted != s.toString()) { + s.replace(0, s.length, formatted) + } + } val sanitized = s.toString().filter { c -> c.isDigit() } if (sanitized.isNotNullOrBlank()) { val countryCode: Int = sanitized.toInt() @@ -262,26 +287,6 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ } false } - - spinnerView.threshold = 100 - spinnerView.setAdapter(spinnerAdapter) - spinnerView.addTextChangedListener(afterTextChanged = ::onCountryDropDownChanged) - } - - private fun onCountryDropDownChanged(s: Editable?) { - if (s.isNullOrEmpty()) { - return - } - - if (s[0] != '+') { - s.insert(0, "+") - } - - fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let { - fragmentViewModel.setCountry(it.digits) - val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0 - phoneNumberInputLayout.setSelection(numberLength, numberLength) - } } private fun presentRegisterButton(sharedState: RegistrationState) { @@ -667,6 +672,10 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ sharedViewModel.setInProgress(false) } + private fun moveToCountryPickerScreen() { + findNavController().safeNavigate(R.id.action_countryPicker) + } + private fun popBackStack() { sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION) findNavController().popBackStack() @@ -687,13 +696,11 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_ menuInflater.inflate(R.menu.enter_phone_number, menu) } - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return if (menuItem.itemId == R.id.phone_menu_use_proxy) { - NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy()) - true - } else { - false - } + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = if (menuItem.itemId == R.id.phone_menu_use_proxy) { + NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy()) + true + } else { + false } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt index cac2d6466c..69ef1845af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.registrationv3.ui.phonenumber import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository +import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country /** * State holder for the phone number entry screen, including phone number and Play Services errors. @@ -15,7 +16,8 @@ data class EnterPhoneNumberState( val phoneNumber: String = "", val phoneNumberRegionCode: String, val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER, - val error: Error = Error.NONE + val error: Error = Error.NONE, + val country: Country? = null ) { enum class Error { NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt index fb6e89f913..c0fb3a2e51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt @@ -13,8 +13,11 @@ import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils import org.thoughtcrime.securesms.registration.util.CountryPrefix import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository +import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter /** * ViewModel for the phone number entry screen. @@ -51,9 +54,7 @@ class EnterPhoneNumberViewModel : ViewModel() { it.copy(mode = value) } - fun countryPrefix(): CountryPrefix { - return supportedCountryPrefixes[store.value.countryPrefixIndex] - } + fun countryPrefix(): CountryPrefix = supportedCountryPrefixes[store.value.countryPrefixIndex] fun setPhoneNumber(phoneNumber: String?) { store.update { it.copy(phoneNumber = phoneNumber ?: "") } @@ -69,7 +70,12 @@ class EnterPhoneNumberViewModel : ViewModel() { store.update { it.copy( countryPrefixIndex = matchingIndex, - phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode + phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode, + country = Country( + name = PhoneNumberFormatter.getRegionDisplayName(supportedCountryPrefixes[matchingIndex].regionCode).orElse(""), + emoji = CountryUtils.countryToEmoji(supportedCountryPrefixes[matchingIndex].regionCode), + countryCode = digits.toString() + ) ) } } diff --git a/app/src/main/res/drawable/country_picker_background.xml b/app/src/main/res/drawable/country_picker_background.xml new file mode 100644 index 0000000000..d5624b9171 --- /dev/null +++ b/app/src/main/res/drawable/country_picker_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_registration_enter_phone_number.xml b/app/src/main/res/layout/fragment_registration_enter_phone_number.xml index ee3ec61304..4dbc480d47 100644 --- a/app/src/main/res/layout/fragment_registration_enter_phone_number.xml +++ b/app/src/main/res/layout/fragment_registration_enter_phone_number.xml @@ -23,35 +23,33 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="24dp" - android:layout_marginTop="32dp" + android:layout_marginTop="20dp" android:layoutDirection="ltr" android:orientation="horizontal" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/verify_subheader"> + app:layout_constraintTop_toBottomOf="@id/country_picker"> + app:errorEnabled="false"> - + android:textAlignment="center" + android:textColor="@color/signal_colorOnSurfaceVariant" + android:text="@string/RegistrationActivity_default_country_code" /> @@ -62,6 +60,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:hint="@string/RegistrationActivity_phone_number_description" + android:layout_marginStart="20dp" android:theme="@style/Signal.ThemeOverlay.TextInputLayout" app:editTextStyle="@style/Signal.ThemeOverlay.TextInputLayout"> @@ -98,11 +97,60 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="24dp" android:layout_marginTop="16dp" - android:text="@string/RegistrationActivity_enter_your_phone_number_to_get_started" + android:text="@string/RegistrationActivity_you_will_receive_a_verification_code" android:textColor="@color/signal_colorOnSurfaceVariant" app:layout_constraintTop_toBottomOf="@+id/verify_header" tools:layout_editor_absoluteX="0dp" /> + + + + + + + + + + diff --git a/app/src/main/res/navigation/registration.xml b/app/src/main/res/navigation/registration.xml index 556ee9cb7b..f9fc023a17 100644 --- a/app/src/main/res/navigation/registration.xml +++ b/app/src/main/res/navigation/registration.xml @@ -58,13 +58,26 @@ + + - + + + + + + + Expand raised hand view + + Your country + + Search by name or number + + Unknown country + Signal connection @@ -2543,6 +2550,12 @@ Additional verification required A verification code will be sent to this number. Carrier rates may apply. + + You will receive a verification code. Carrier rates may apply. + + Select a country + + +0 You\'ll receive a call to verify this number. Edit number Missing Google Play Services