Skip to content

Data, search and recents

The Country model

com.ezzy.ccp.countrypicker.model.Country is the canonical country representation for the whole library.

@Immutable
data class Country(
    val iso2Code: String,          // "KE" β€” the stable identity
    val iso3Code: String,          // "KEN"
    val displayName: String,       // "Kenya"
    val dialCode: String,          // "+254"
    val flag: String?,             // "πŸ‡°πŸ‡ͺ", or null for the few codes with no emoji (Kosovo)
    val region: CountryRegion,     // Africa, Americas, Asia, Europe, Oceania
    val alternativeNames: List<String> = emptyList(),  // search aliases, never shown
) {
    val dialCodeDigits: String     // "254"
}

Identity is iso2Code and nothing else. Dial codes are not unique: +1 is shared by the US, Canada and a dozen Caribbean territories, +44 by the UK, Jersey, Guernsey and the Isle of Man. So equals, hashCode and every list key derive from the ISO alpha-2 code alone. Instances are @Immutable and safe to use as Compose keys.

The bundled dataset

DefaultCountryDataSource ships every ISO 3166-1 country and territory with an international dial code: 236 entries, each with ISO alpha-2, alpha-3, region and search aliases. It is fully local, parsed once on first access, and is the single source of truth for country metadata (the legacy countryList is derived from it).

Member Purpose
countries All countries, English names, sorted by name
findByIso2("ke") Lookup by ISO alpha-2, case-insensitive. UK maps to GB
findByDialCode("+1") Every country sharing a dial code, as a list
primaryForDialCode("+1") The conventional owner of a shared code: +1 β†’ United States, +44 β†’ United Kingdom, +7 β†’ Russia
localizedNames(locale) The dataset with names resolved through Locale.getDisplayCountry, re-sorted with a locale-aware collator
normalizeIsoCode("uk") Uppercases and maps well-known non-ISO codes to their canonical form
flagEmoji("KE") The regional-indicator emoji for a code, or null when none exists

Names follow current ISO usage (Czechia, TΓΌrkiye, Eswatini), and former names remain searchable as aliases. Dial codes for North American territories carry their full prefix (+1876 for Jamaica), so the value is directly usable for display and prefix matching.

Localized names

The library ships no translation table. To show localized names, wrap the dataset in a data source:

val localized = CountryDataSource { DefaultCountryDataSource.localizedNames(Locale.current) }
val repository = remember { CountryRepository(localized) }

CountrySelector(
    selectedCountry = country,
    onCountrySelected = { country = it },
    repository = repository,
)

The English name is kept as an alias, so searching "Germany" still finds "Allemagne".

Custom data sources

CountryDataSource is a fun interface with one suspend fun load(): List<Country>. Implement it to serve countries from a backend "supported markets" endpoint or a cut-down test fixture:

val source = CountryDataSource { api.supportedMarkets().map(::toCountry) }
val repository = remember { CountryRepository(source) }

CountryRepository caches the result and applies the allow/exclude rules, so reopening the sheet, rotating the device or retrying after an error does not re-hit a remote source. Concurrent loads are collapsed into one. It holds no UI state and is safe to keep in a singleton or DI graph; CountryRepository.Default is the shared instance over the bundled dataset.

With a remote source the sheet gains two states the bundled dataset never shows: skeleton rows while loading, and an error state with a Retry action that calls repository.invalidate() and reloads. A failure becomes CountryLoadState.Error(message, offline) rather than an exception escaping into composition.

CountrySearchEngine.search(countries, query, fields, minQueryLength) is the purely local search the sheet uses. The query is trimmed, lowercased, accent-folded and stripped of a leading +, so "+254" and "254" behave identically. Results are ordered by match quality, then by name, so the same query always produces the same order.

Rank Match
0 Country name equals the query
1 Country name starts with the query
2 ISO alpha-2 or alpha-3 equals the query
3 Dial code equals the query
4 Country name contains the query
5 An alternative name contains the query
6 ISO alpha-2 or alpha-3 starts with the query
7 Dial code starts with the query

The last two tiers are partial-input fallbacks so a half-typed code ("25", "ke") still surfaces something without outranking a real name or exact-code match. Each result is a CountryMatch carrying the country, the field that matched, the highlight range within the name, and the rank.

Recently selected countries

RecentCountryStore persists the countries a user has chosen so the sheet can offer them first. Only ISO alpha-2 codes cross this boundary, never search queries or anything else the user typed. Recording a country the user picked is a preference; recording what they searched for would be tracking, and the interface makes the two impossible to confuse.

interface RecentCountryStore {
    fun observeRecentCountryCodes(): Flow<List<String>>   // most recent first, deduplicated
    suspend fun recordSelection(countryCode: String)
    suspend fun clear()                                    // wire to your "clear data" affordance
}
Implementation Use
NoOpRecentCountryStore The default. Remembers nothing, so the library never writes to disk unless you opt in
rememberDefaultRecentCountryStore(maxEntries = 5, fileName) Persistent, with an in-memory mirror: SharedPreferences on Android, NSUserDefaults on iOS. All disk work is on Dispatchers.IO; reads after construction are served from memory so the Recent section renders on the first frame. Outside composition: DefaultRecentCountryStore(context) on Android, DefaultRecentCountryStore() on iOS
InMemoryRecentCountryStore(initial) Survives recomposition but not process death. For previews, tests, and hosts that want the section without persisting
val recents = rememberDefaultRecentCountryStore()

CountrySelector(
    selectedCountry = country,
    onCountrySelected = { country = it },
    recentCountryStore = recents,
)

The platform stores rather than DataStore is deliberate: adding androidx.datastore and its transitive graph to every consumer's app to store five two-letter strings is not a good trade. An app that already uses DataStore can implement the interface over its own store in a dozen lines.

Hosts that must not persist anything (kiosk, guest, incognito modes) keep the default no-op store or set showRecentlySelected = false.

Regions

CountryRegion has five values (Africa, Americas, Asia, Europe, Oceania), each with a stable, locale-independent key that is safe to persist, and CountryRegion.fromKey(key) to resolve one back.

Converting to and from the legacy model

com.ezzy.ccp.countrypicker.model includes conversions so the legacy PhoneNumberInput API can run on the new foundation. They are public in case you need them while migrating:

Function Direction
LegacyCountry.toCanonical(): Country? Legacy com.ezzy.ccp.model.Country β†’ canonical, matched on ISO alpha-2
Country.toLegacy(): LegacyCountry Canonical β†’ legacy (lossy: alpha-3, region and aliases are dropped)
Country.toSelectedCountry(): SelectedCountry Canonical β†’ the legacy SelectedCountry used by Phone
PhoneNumberValue.toLegacyPhone(): Phone New phone value β†’ legacy Phone snapshot
resolveCountryByCodeOrName("Kenya") ISO alpha-2, alpha-3, exact name or alias β†’ Country?, the way the legacy setCountry parameter worked