mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-07-15 02:02:58 +01:00
Add the country code picker to regV5.
This commit is contained in:
committed by
Alex Hart
parent
7b362460e7
commit
c91275c5da
+25
-3
@@ -38,6 +38,10 @@ import org.signal.registration.screens.accountlocked.AccountLockedState
|
||||
import org.signal.registration.screens.captcha.CaptchaScreen
|
||||
import org.signal.registration.screens.captcha.CaptchaScreenEvents
|
||||
import org.signal.registration.screens.captcha.CaptchaState
|
||||
import org.signal.registration.screens.countrycode.Country
|
||||
import org.signal.registration.screens.countrycode.CountryCodePickerRepository
|
||||
import org.signal.registration.screens.countrycode.CountryCodePickerScreen
|
||||
import org.signal.registration.screens.countrycode.CountryCodePickerViewModel
|
||||
import org.signal.registration.screens.permissions.PermissionsScreen
|
||||
import org.signal.registration.screens.phonenumber.PhoneNumberEntryScreenEvents
|
||||
import org.signal.registration.screens.phonenumber.PhoneNumberEntryViewModel
|
||||
@@ -117,6 +121,7 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
|
||||
}
|
||||
|
||||
private const val CAPTCHA_RESULT = "captcha_token"
|
||||
private const val COUNTRY_CODE_RESULT = "country_code_result"
|
||||
|
||||
/**
|
||||
* Sets up the navigation graph for the registration flow using Navigation 3.
|
||||
@@ -268,6 +273,12 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
}
|
||||
}
|
||||
|
||||
ResultEffect<Country?>(registrationViewModel.resultBus, COUNTRY_CODE_RESULT) { country ->
|
||||
if (country != null) {
|
||||
viewModel.onEvent(PhoneNumberEntryScreenEvents.CountrySelected(country.countryCode, country.regionCode, country.name, country.emoji))
|
||||
}
|
||||
}
|
||||
|
||||
PhoneNumberScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
@@ -276,9 +287,20 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
|
||||
// -- Country Code Picker
|
||||
entry<RegistrationRoute.CountryCodePicker> {
|
||||
// We'll also want this to be some sort of launch-for-result flow as well
|
||||
// TODO [registration] - display country code picker
|
||||
throw NotImplementedError("Country Code Picker not implemented")
|
||||
val viewModel: CountryCodePickerViewModel = viewModel(
|
||||
factory = CountryCodePickerViewModel.Factory(
|
||||
repository = CountryCodePickerRepository(),
|
||||
parentEventEmitter = parentEventEmitter,
|
||||
resultBus = registrationViewModel.resultBus,
|
||||
resultKey = COUNTRY_CODE_RESULT
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
CountryCodePickerScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// -- Captcha Screen
|
||||
|
||||
+4
-4
@@ -30,10 +30,6 @@ import java.util.Locale
|
||||
|
||||
class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationRepository::class)
|
||||
}
|
||||
|
||||
suspend fun createSession(e164: String): RegistrationNetworkResult<SessionMetadata, CreateSessionError> = withContext(Dispatchers.IO) {
|
||||
val fcmToken = networkController.getFcmToken()
|
||||
networkController.createSession(
|
||||
@@ -308,4 +304,8 @@ class RegistrationRepository(val context: Context, val networkController: Networ
|
||||
suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? {
|
||||
return storageController.getPreExistingRegistrationData()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationRepository::class)
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.countrycode
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Data class string describing useful characteristics of countries when selecting one. Used in the [CountryCodeState]
|
||||
* An example is: Country(emoji=🇺🇸, name = "United States", countryCode = 1, regionCode= "US")
|
||||
*/
|
||||
@Parcelize
|
||||
data class Country(val emoji: String, val name: String, val countryCode: Int, val regionCode: String) : Parcelable
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.countrycode
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.E164Util
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Repository for fetching country data used by the country code picker.
|
||||
*/
|
||||
class CountryCodePickerRepository {
|
||||
|
||||
companion object {
|
||||
/** A hardcoded list of countries to suggest during registration. Can change at any time. */
|
||||
private val COMMON_COUNTRIES = listOf("US", "DE", "IN", "NL", "UA")
|
||||
}
|
||||
|
||||
suspend fun getCountries(): List<Country> = withContext(Dispatchers.IO) {
|
||||
val collator = Collator.getInstance(Locale.getDefault())
|
||||
collator.strength = Collator.PRIMARY
|
||||
|
||||
PhoneNumberUtil.getInstance().supportedRegions
|
||||
.map { region ->
|
||||
Country(
|
||||
name = E164Util.getRegionDisplayName(region).orElse(""),
|
||||
emoji = countryToEmoji(region),
|
||||
countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(region),
|
||||
regionCode = region
|
||||
)
|
||||
}.sortedWith { lhs, rhs ->
|
||||
collator.compare(lhs.name.lowercase(Locale.getDefault()), rhs.name.lowercase(Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCommonCountries(): List<Country> = withContext(Dispatchers.IO) {
|
||||
COMMON_COUNTRIES.map { region ->
|
||||
Country(
|
||||
name = E164Util.getRegionDisplayName(region).orElse(""),
|
||||
emoji = countryToEmoji(region),
|
||||
countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(region),
|
||||
regionCode = region
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private 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 {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.countrycode
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.LaunchedEffect
|
||||
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.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.KeyboardType
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.IconButtons.IconButton
|
||||
import org.signal.core.ui.compose.LargeFontPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.registration.R
|
||||
|
||||
/**
|
||||
* Screen that allows someone to search and select a country code from a supported list of countries.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CountryCodePickerScreen(
|
||||
state: CountryCodeState,
|
||||
onEvent: (CountryCodePickerScreenEvents) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Scaffolds.DefaultTopAppBar(
|
||||
title = stringResource(R.string.CountryCodeSelectScreen__your_country),
|
||||
titleContent = { _, title ->
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
},
|
||||
onNavigationClick = { onEvent(CountryCodePickerScreenEvents.Dismissed) },
|
||||
navigationIcon = SignalIcons.X.imageVector,
|
||||
navigationContentDescription = stringResource(R.string.CountryCodeSelectScreen__close)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
stickyHeader {
|
||||
SearchBar(
|
||||
text = state.query,
|
||||
onSearch = { onEvent(CountryCodePickerScreenEvents.Search(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.countryList.isEmpty()) {
|
||||
item {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
}
|
||||
} else if (state.query.isEmpty()) {
|
||||
if (state.commonCountryList.isNotEmpty()) {
|
||||
items(state.commonCountryList) { country ->
|
||||
CountryItem(country, onEvent)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
}
|
||||
|
||||
items(state.countryList) { country ->
|
||||
CountryItem(country, onEvent)
|
||||
}
|
||||
} else {
|
||||
items(state.filteredList) { country ->
|
||||
CountryItem(country, onEvent, state.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.startingIndex) {
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(index = state.startingIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CountryItem(
|
||||
country: Country,
|
||||
onEvent: (CountryCodePickerScreenEvents) -> 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 { onEvent(CountryCodePickerScreenEvents.CountrySelected(country)) }
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = name.ifEmpty { stringResource(R.string.CountryCodeSelectScreen__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
|
||||
private fun SearchBar(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
hint: String = stringResource(R.string.CountryCodeSelectScreen__search_by),
|
||||
onSearch: (String) -> Unit = {}
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = { onSearch(it) },
|
||||
placeholder = { Text(hint) },
|
||||
trailingIcon = {
|
||||
if (text.isNotEmpty()) {
|
||||
IconButton(onClick = { onSearch("") }) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.X.imageVector,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
showKeyboard = !showKeyboard
|
||||
focusRequester.requestFocus()
|
||||
}) {
|
||||
if (showKeyboard) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Keyboard.imageVector,
|
||||
contentDescription = null
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (showKeyboard) {
|
||||
KeyboardType.Number
|
||||
} else {
|
||||
KeyboardType.Text
|
||||
}
|
||||
),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(bottom = 18.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 54.dp)
|
||||
.focusRequester(focusRequester),
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ScreenPreview() {
|
||||
Previews.Preview {
|
||||
CountryCodePickerScreen(
|
||||
state = CountryCodeState(
|
||||
countryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 2, "CA"),
|
||||
Country("\uD83C\uDDF2\uD83C\uDDFD", "Mexico", 3, "MX")
|
||||
),
|
||||
commonCountryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 4, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 5, "CA")
|
||||
)
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun LoadingScreenPreview() {
|
||||
Previews.Preview {
|
||||
CountryCodePickerScreen(
|
||||
state = CountryCodeState(
|
||||
countryList = emptyList()
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@LargeFontPreviews
|
||||
@Composable
|
||||
private fun LargeFontScreenPreview() {
|
||||
Previews.Preview {
|
||||
CountryCodePickerScreen(
|
||||
state = CountryCodeState(
|
||||
countryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 2, "CA"),
|
||||
Country("\uD83C\uDDF2\uD83C\uDDFD", "Mexico", 3, "MX")
|
||||
),
|
||||
commonCountryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 4, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 5, "CA")
|
||||
)
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.countrycode
|
||||
|
||||
sealed interface CountryCodePickerScreenEvents {
|
||||
data class Search(val query: String) : CountryCodePickerScreenEvents
|
||||
data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents
|
||||
data object Dismissed : CountryCodePickerScreenEvents
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.countrycode
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.navigation.ResultEventBus
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.screens.util.navigateBack
|
||||
|
||||
/**
|
||||
* View model for the country code picker screen.
|
||||
* Handles search filtering and country selection, emitting results back via [ResultEventBus].
|
||||
*/
|
||||
class CountryCodePickerViewModel(
|
||||
private val repository: CountryCodePickerRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
private val resultBus: ResultEventBus,
|
||||
private val resultKey: String,
|
||||
initialCountry: Country? = null
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(CountryCodeState())
|
||||
val state: StateFlow<CountryCodeState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCountries(initialCountry)
|
||||
}
|
||||
|
||||
fun onEvent(event: CountryCodePickerScreenEvents) {
|
||||
when (event) {
|
||||
is CountryCodePickerScreenEvents.Search -> applySearchEvent(event.query)
|
||||
is CountryCodePickerScreenEvents.CountrySelected -> {
|
||||
resultBus.sendResult(resultKey, event.country)
|
||||
parentEventEmitter.navigateBack()
|
||||
}
|
||||
is CountryCodePickerScreenEvents.Dismissed -> {
|
||||
parentEventEmitter.navigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySearchEvent(filterBy: String) {
|
||||
if (filterBy.isEmpty()) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = filterBy,
|
||||
filteredList = emptyList()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = filterBy,
|
||||
filteredList = _state.value.countryList.filter { country: Country ->
|
||||
country.name.contains(filterBy, ignoreCase = true) ||
|
||||
country.countryCode.toString().contains(filterBy.removePrefix("+")) ||
|
||||
(filterBy.equals("usa", ignoreCase = true) && country.name.equals("United States", ignoreCase = true))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCountries(initialCountry: Country? = null) {
|
||||
viewModelScope.launch {
|
||||
val countryList = repository.getCountries()
|
||||
val commonCountryList = repository.getCommonCountries()
|
||||
val startingIndex = if (initialCountry == null || commonCountryList.contains(initialCountry)) {
|
||||
0
|
||||
} else {
|
||||
countryList.indexOf(initialCountry) + commonCountryList.size
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
countryList = countryList,
|
||||
commonCountryList = commonCountryList,
|
||||
startingIndex = startingIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: CountryCodePickerRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
private val resultBus: ResultEventBus,
|
||||
private val resultKey: String,
|
||||
private val initialCountry: Country? = null
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return CountryCodePickerViewModel(repository, parentEventEmitter, resultBus, resultKey, initialCountry) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.countrycode
|
||||
|
||||
/**
|
||||
* State managed by [CountryCodePickerViewModel]. Includes country list and allows for searching
|
||||
*/
|
||||
data class CountryCodeState(
|
||||
val query: String = "",
|
||||
val countryList: List<Country> = emptyList(),
|
||||
val commonCountryList: List<Country> = emptyList(),
|
||||
val filteredList: List<Country> = emptyList(),
|
||||
val startingIndex: Int = 0
|
||||
)
|
||||
+107
-61
@@ -10,13 +10,19 @@ package org.signal.registration.screens.permissions
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -31,8 +37,8 @@ import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.registration.R
|
||||
import org.signal.registration.screens.shared.RegistrationScreen
|
||||
import org.signal.registration.screens.util.MockMultiplePermissionsState
|
||||
import org.signal.registration.screens.util.MockPermissionsState
|
||||
import org.signal.registration.test.TestTags
|
||||
@@ -53,79 +59,119 @@ fun PermissionsScreen(
|
||||
) {
|
||||
val permissions = permissionsState.permissions.map { it.permission }
|
||||
|
||||
RegistrationScreen(
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
|
||||
modifier = modifier.testTag(TestTags.PERMISSIONS_SCREEN),
|
||||
bottomContent = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
Surface(modifier = modifier.testTag(TestTags.PERMISSIONS_SCREEN)) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.weight(weight = 1f, fill = false)
|
||||
.padding(bottom = 16.dp)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
TextButton(
|
||||
modifier = Modifier
|
||||
.weight(weight = 1f, fill = false)
|
||||
.testTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON),
|
||||
onClick = onProceed
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
if (permissions.any { it == Manifest.permission.POST_NOTIFICATIONS }) {
|
||||
PermissionRow(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
if (permissions.any { it == Manifest.permission.READ_CONTACTS || it == Manifest.permission.WRITE_CONTACTS }) {
|
||||
PermissionRow(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
modifier = Modifier.testTag(TestTags.PERMISSIONS_NEXT_BUTTON),
|
||||
onClick = {
|
||||
permissionsState.launchMultiplePermissionRequest()
|
||||
onProceed()
|
||||
if (permissions.any {
|
||||
it == Manifest.permission.READ_EXTERNAL_STORAGE ||
|
||||
it == Manifest.permission.WRITE_EXTERNAL_STORAGE ||
|
||||
it == Manifest.permission.READ_MEDIA_IMAGES ||
|
||||
it == Manifest.permission.READ_MEDIA_VIDEO ||
|
||||
it == Manifest.permission.READ_MEDIA_AUDIO
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.GrantPermissionsFragment__next)
|
||||
PermissionRow(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__storage),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
|
||||
)
|
||||
}
|
||||
|
||||
if (permissions.any { it == Manifest.permission.READ_PHONE_STATE || it == Manifest.permission.READ_PHONE_NUMBERS }) {
|
||||
PermissionRow(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (permissions.any { it == Manifest.permission.POST_NOTIFICATIONS }) {
|
||||
PermissionRow(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
|
||||
)
|
||||
}
|
||||
|
||||
if (permissions.any { it == Manifest.permission.READ_CONTACTS || it == Manifest.permission.WRITE_CONTACTS }) {
|
||||
PermissionRow(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 24.dp)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(
|
||||
modifier = Modifier
|
||||
.weight(weight = 1f, fill = false)
|
||||
.testTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON),
|
||||
onClick = onProceed
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
|
||||
)
|
||||
}
|
||||
|
||||
if (permissions.any {
|
||||
it == Manifest.permission.READ_EXTERNAL_STORAGE ||
|
||||
it == Manifest.permission.WRITE_EXTERNAL_STORAGE ||
|
||||
it == Manifest.permission.READ_MEDIA_IMAGES ||
|
||||
it == Manifest.permission.READ_MEDIA_VIDEO ||
|
||||
it == Manifest.permission.READ_MEDIA_AUDIO
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
Buttons.LargeTonal(
|
||||
modifier = Modifier.testTag(TestTags.PERMISSIONS_NEXT_BUTTON),
|
||||
onClick = {
|
||||
permissionsState.launchMultiplePermissionRequest()
|
||||
onProceed()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.GrantPermissionsFragment__next)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
PermissionRow(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__storage),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
|
||||
)
|
||||
}
|
||||
|
||||
if (permissions.any { it == Manifest.permission.READ_PHONE_STATE || it == Manifest.permission.READ_PHONE_NUMBERS }) {
|
||||
PermissionRow(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
|
||||
title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
|
||||
subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -87,9 +87,8 @@ fun PhoneNumberScreen(
|
||||
|
||||
@Composable
|
||||
private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEntryScreenEvents) -> Unit) {
|
||||
// TODO: These should come from state once country picker is implemented
|
||||
var selectedCountry by remember { mutableStateOf("United States") }
|
||||
var selectedCountryEmoji by remember { mutableStateOf("🇺🇸") }
|
||||
val selectedCountry = state.countryName
|
||||
val selectedCountryEmoji = state.countryEmoji
|
||||
|
||||
// Track the phone number text field value with cursor position
|
||||
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) }
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ package org.signal.registration.screens.phonenumber
|
||||
sealed interface PhoneNumberEntryScreenEvents {
|
||||
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents
|
||||
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents
|
||||
data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents
|
||||
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents
|
||||
data object CountryPicker : PhoneNumberEntryScreenEvents
|
||||
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents
|
||||
|
||||
+2
@@ -13,6 +13,8 @@ import kotlin.time.Duration
|
||||
data class PhoneNumberEntryState(
|
||||
val regionCode: String = "US",
|
||||
val countryCode: String = "1",
|
||||
val countryName: String = "United States",
|
||||
val countryEmoji: String = "\uD83C\uDDFA\uD83C\uDDF8",
|
||||
val nationalNumber: String = "",
|
||||
val formattedNumber: String = "",
|
||||
val sessionE164: String? = null,
|
||||
|
||||
+19
@@ -74,6 +74,9 @@ class PhoneNumberEntryViewModel(
|
||||
is PhoneNumberEntryScreenEvents.CountryCodeChanged -> {
|
||||
stateEmitter(applyCountryCodeChanged(state, event.value))
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.CountrySelected -> {
|
||||
stateEmitter(applyCountrySelected(state, event.countryCode, event.regionCode, event.countryName, event.countryEmoji))
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> {
|
||||
stateEmitter(applyPhoneNumberChanged(state, event.value))
|
||||
}
|
||||
@@ -105,6 +108,22 @@ class PhoneNumberEntryViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyCountrySelected(state: PhoneNumberEntryState, countryCode: Int, regionCode: String, countryName: String, countryEmoji: String): PhoneNumberEntryState {
|
||||
val countryCodeStr = countryCode.toString()
|
||||
if (countryCodeStr == state.countryCode && regionCode == state.regionCode) return state
|
||||
|
||||
formatter = phoneNumberUtil.getAsYouTypeFormatter(regionCode)
|
||||
val formattedNumber = formatNumber(state.nationalNumber)
|
||||
|
||||
return state.copy(
|
||||
countryCode = countryCodeStr,
|
||||
regionCode = regionCode,
|
||||
countryName = countryName,
|
||||
countryEmoji = countryEmoji,
|
||||
formattedNumber = formattedNumber
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyCountryCodeChanged(state: PhoneNumberEntryState, countryCode: String): PhoneNumberEntryState {
|
||||
// Only allow digits, max 3 characters
|
||||
val sanitized = countryCode.filter { it.isDigit() }.take(3)
|
||||
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.shared
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
|
||||
/**
|
||||
* A base framework for rendering the various registration screens.
|
||||
*/
|
||||
@Composable
|
||||
fun RegistrationScreen(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
bottomContent: @Composable (BoxScope.() -> Unit),
|
||||
modifier: Modifier = Modifier,
|
||||
mainContent: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(modifier = modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.weight(weight = 1f, fill = false)
|
||||
.padding(bottom = 16.dp)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
mainContent()
|
||||
}
|
||||
|
||||
Surface(
|
||||
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 24.dp)
|
||||
.horizontalGutters()
|
||||
) {
|
||||
bottomContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RegistrationScreenPreview() {
|
||||
Previews.Preview {
|
||||
RegistrationScreen(
|
||||
title = "Title",
|
||||
subtitle = "Subtitle",
|
||||
bottomContent = {
|
||||
TextButton(onClick = {}) {
|
||||
Text("Bottom Button")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Main content")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8.38 4.75c0 0.9-0.73 1.63-1.63 1.63-0.9 0-1.63-0.73-1.63-1.63 0-0.9 0.73-1.63 1.63-1.63 0.9 0 1.63 0.73 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12 6.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.25 6.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12 21.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M13.63 14.75c0 0.9-0.73 1.63-1.63 1.63-0.9 0-1.63-0.73-1.63-1.63 0-0.9 0.73-1.63 1.63-1.63 0.9 0 1.63 0.73 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.25 16.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8.38 14.75c0 0.9-0.73 1.63-1.63 1.63-0.9 0-1.63-0.73-1.63-1.63 0-0.9 0.73-1.63 1.63-1.63 0.9 0 1.63 0.73 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12 11.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M18.88 9.75c0 0.9-0.73 1.63-1.63 1.63-0.9 0-1.63-0.73-1.63-1.63 0-0.9 0.73-1.63 1.63-1.63 0.9 0 1.63 0.73 1.63 1.63Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6.75 11.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
|
||||
</vector>
|
||||
@@ -32,4 +32,14 @@
|
||||
<string name="GrantPermissionsFragment__storage">Storage</string>
|
||||
<!-- Storage permission row description -->
|
||||
<string name="GrantPermissionsFragment__send_photos_videos_and_files">Send photos, videos and files from your device.</string>
|
||||
|
||||
<!-- CountryCodePickerScreen -->
|
||||
<!-- Title of the country code selection screen -->
|
||||
<string name="CountryCodeSelectScreen__your_country">Your country</string>
|
||||
<!-- Close button content description -->
|
||||
<string name="CountryCodeSelectScreen__close">Close</string>
|
||||
<!-- Search bar hint text -->
|
||||
<string name="CountryCodeSelectScreen__search_by">Search by name or number</string>
|
||||
<!-- Fallback text for countries with no name -->
|
||||
<string name="CountryCodeSelectScreen__unknown_country">Unknown country</string>
|
||||
</resources>
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.countrycode
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isGreaterThan
|
||||
import assertk.assertions.isNotEmpty
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.core.ui.navigation.ResultEventBus
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class CountryCodePickerViewModelTest {
|
||||
|
||||
private lateinit var mockRepository: CountryCodePickerRepository
|
||||
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
|
||||
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
private lateinit var resultBus: ResultEventBus
|
||||
private val resultKey = "test_country_result"
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
private val testCountries = listOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 1, "CA"),
|
||||
Country("\uD83C\uDDE9\uD83C\uDDEA", "Germany", 49, "DE"),
|
||||
Country("\uD83C\uDDEC\uD83C\uDDE7", "United Kingdom", 44, "GB"),
|
||||
Country("\uD83C\uDDEE\uD83C\uDDF3", "India", 91, "IN"),
|
||||
Country("\uD83C\uDDF3\uD83C\uDDF1", "Netherlands", 31, "NL"),
|
||||
Country("\uD83C\uDDFA\uD83C\uDDE6", "Ukraine", 380, "UA")
|
||||
)
|
||||
|
||||
private val testCommonCountries = listOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US"),
|
||||
Country("\uD83C\uDDE9\uD83C\uDDEA", "Germany", 49, "DE"),
|
||||
Country("\uD83C\uDDEE\uD83C\uDDF3", "India", 91, "IN"),
|
||||
Country("\uD83C\uDDF3\uD83C\uDDF1", "Netherlands", 31, "NL"),
|
||||
Country("\uD83C\uDDFA\uD83C\uDDE6", "Ukraine", 380, "UA")
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockRepository = mockk(relaxed = true)
|
||||
emittedEvents = mutableListOf()
|
||||
parentEventEmitter = { event -> emittedEvents.add(event) }
|
||||
resultBus = ResultEventBus()
|
||||
|
||||
coEvery { mockRepository.getCountries() } returns testCountries
|
||||
coEvery { mockRepository.getCommonCountries() } returns testCommonCountries
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel(initialCountry: Country? = null): CountryCodePickerViewModel {
|
||||
return CountryCodePickerViewModel(mockRepository, parentEventEmitter, resultBus, resultKey, initialCountry)
|
||||
}
|
||||
|
||||
// ==================== Initialization Tests ====================
|
||||
|
||||
@Test
|
||||
fun `initial state has empty lists before loading completes`() {
|
||||
val state = CountryCodeState()
|
||||
|
||||
assertThat(state.query).isEqualTo("")
|
||||
assertThat(state.countryList).isEqualTo(emptyList())
|
||||
assertThat(state.commonCountryList).isEqualTo(emptyList())
|
||||
assertThat(state.filteredList).isEqualTo(emptyList())
|
||||
assertThat(state.startingIndex).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadCountries populates country list and common country list`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.countryList).isNotEmpty()
|
||||
assertThat(state.commonCountryList).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadCountries includes United States in country list`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
val us = state.countryList.find { it.regionCode == "US" }
|
||||
assertThat(us != null).isTrue()
|
||||
assertThat(us!!.countryCode).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadCountries includes common countries`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
val commonRegionCodes = state.commonCountryList.map { it.regionCode }
|
||||
assertThat(commonRegionCodes).contains("US")
|
||||
assertThat(commonRegionCodes).contains("DE")
|
||||
assertThat(commonRegionCodes).contains("IN")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadCountries with initialCountry not in common list sets starting index`() = runTest {
|
||||
val canada = testCountries.find { it.regionCode == "CA" }!!
|
||||
val viewModel = createViewModel(initialCountry = canada)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.startingIndex).isGreaterThan(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadCountries with common initialCountry keeps starting index at 0`() = runTest {
|
||||
val us = testCommonCountries.find { it.regionCode == "US" }!!
|
||||
val viewModel = createViewModel(initialCountry = us)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.startingIndex).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadCountries with null initialCountry keeps starting index at 0`() = runTest {
|
||||
val viewModel = createViewModel(initialCountry = null)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.startingIndex).isEqualTo(0)
|
||||
}
|
||||
|
||||
// ==================== Search Tests ====================
|
||||
|
||||
@Test
|
||||
fun `Search event filters countries by name`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Search("United"))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.query).isEqualTo("United")
|
||||
assertThat(state.filteredList).isNotEmpty()
|
||||
assertThat(state.filteredList.all { it.name.contains("United", ignoreCase = true) }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Search event filters countries by country code`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Search("49"))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.filteredList).isNotEmpty()
|
||||
assertThat(state.filteredList.any { it.countryCode == 49 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Search event filters countries by country code with plus prefix`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Search("+49"))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.filteredList).isNotEmpty()
|
||||
assertThat(state.filteredList.any { it.countryCode == 49 }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Search event is case insensitive`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Search("germany"))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.filteredList).isNotEmpty()
|
||||
assertThat(state.filteredList.any { it.regionCode == "DE" }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Search for USA matches United States`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Search("usa"))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.filteredList).isNotEmpty()
|
||||
assertThat(state.filteredList.any { it.regionCode == "US" }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Empty search clears filtered list`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// First, search for something
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Search("United"))
|
||||
assertThat(viewModel.state.value.filteredList).isNotEmpty()
|
||||
|
||||
// Then clear it
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Search(""))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.query).isEqualTo("")
|
||||
assertThat(state.filteredList).isEqualTo(emptyList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Search with no matches returns empty filtered list`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Search("xyznonexistent"))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertThat(state.filteredList).isEqualTo(emptyList())
|
||||
}
|
||||
|
||||
// ==================== CountrySelected Tests ====================
|
||||
|
||||
@Test
|
||||
fun `CountrySelected sends result via result bus and navigates back`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val country = Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US")
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.CountrySelected(country))
|
||||
|
||||
val result = resultBus.channelMap[resultKey]?.tryReceive()?.getOrNull()
|
||||
assertThat(result).isEqualTo(country)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dismissed does not send result to result bus`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Dismissed)
|
||||
|
||||
val result = resultBus.channelMap[resultKey]?.tryReceive()?.getOrNull()
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
// ==================== Dismissed Tests ====================
|
||||
|
||||
@Test
|
||||
fun `Dismissed navigates back`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onEvent(CountryCodePickerScreenEvents.Dismissed)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user