diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 741a31e6fb..c46c3a67cc 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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.navigationEntries( } } + ResultEffect(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.navigationEntries( // -- Country Code Picker entry { - // 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 diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index 59128088d9..102e11e852 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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 = 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) + } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/Country.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/Country.kt new file mode 100644 index 0000000000..3ed56ff886 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/Country.kt @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerRepository.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerRepository.kt new file mode 100644 index 0000000000..5f764d6aa6 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerRepository.kt @@ -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 = 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 = 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 { + "" + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreen.kt new file mode 100644 index 0000000000..5e52b9248c --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreenEvents.kt new file mode 100644 index 0000000000..6d370f54f3 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerScreenEvents.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModel.kt new file mode 100644 index 0000000000..cf67af0093 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModel.kt @@ -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 = _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 create(modelClass: Class): T { + return CountryCodePickerViewModel(repository, parentEventEmitter, resultBus, resultKey, initialCountry) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodeState.kt b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodeState.kt new file mode 100644 index 0000000000..8063dbb26f --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/countrycode/CountryCodeState.kt @@ -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 = emptyList(), + val commonCountryList: List = emptyList(), + val filteredList: List = emptyList(), + val startingIndex: Int = 0 +) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt index da739dff0c..f9ea114734 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt @@ -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) - ) } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt index ef04bb4af4..0cf6f9198a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt @@ -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)) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt index e548b492b5..26caae414f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt index d19c16f45b..aca0733b0e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt @@ -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, diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index c00d476280..a2ecb9e06f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -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) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/shared/RegistrationScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/shared/RegistrationScreen.kt deleted file mode 100644 index 502c4961ca..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/shared/RegistrationScreen.kt +++ /dev/null @@ -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") - } - } -} diff --git a/feature/registration/src/main/res/drawable/symbol_number_pad_24.xml b/feature/registration/src/main/res/drawable/symbol_number_pad_24.xml new file mode 100644 index 0000000000..b43c69abdb --- /dev/null +++ b/feature/registration/src/main/res/drawable/symbol_number_pad_24.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index f392f2e25d..b6f2452bf7 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -32,4 +32,14 @@ Storage Send photos, videos and files from your device. + + + + Your country + + Close + + Search by name or number + + Unknown country diff --git a/feature/registration/src/test/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModelTest.kt new file mode 100644 index 0000000000..3eabbf83d2 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/countrycode/CountryCodePickerViewModelTest.kt @@ -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 + 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) + } +}