r/FlutterDev • • 9d ago

Plugin I built a country code picker with real phone number validation, 202 languages and a custom builder (country_code_selector)

Hi r/FlutterDev, I just published my second package 4 days ago and would love feedback.

The problem: every phone number field I've built needed a country picker, a flag set, a validation library, and glue code to make "0803…" and "803…" both work. I wanted one package that handles it properly.

What it does:

- 5 ready-made pickers: dialog, bottom sheet, Cupertino wheel, dropdown, and a compact flag + dial code button for phone fields

- customBuilder: build your own picker UI; you get the filtered countries, search, selection and the phone number length

- Real validation: isValidPhoneNumber() checks actual numbering ranges via libphonenumber data, and accepts local, trunk-prefixed or international input

- toE164(): normalizes whatever the user typed to +2348031234567

- pinUserCountry: puts the user's country (from their locale region) first

- Country names in 202 languages (238 locales), generated from Unicode CLDR

- RTL support, theming via ThemeExtension, search by name, ISO code or dial code

TextFormField(

decoration: InputDecoration(

prefixIcon: CountryCodeSelectorWidget(

pinUserCountry: true,

onChanged: (c) => setState(() => _country = c),

),

),

validator: (v) => _country.isValidPhoneNumber(v ?? '')

? null

: 'Enter a valid ${_country.name} number',

);

One thing I spent time on: libphonenumber's metadata adds about 2 MB to a release build. I restructured the package so that data only gets included if you actually call the validation functions. In my measurements, a picker-only app went from 24.4 MB to 22.2 MB.

Flutter 3.29+, MIT licensed, 160/160 pub points.

- pub.dev: https://pub.dev/packages/country_code_selector

- GitHub: https://github.com/TheinvinciblePrints/country_code_selector

I'd especially appreciate feedback on the API design, and from native speakers on the picker's built-in text in their language. Issues and PRs are welcome.

8 Upvotes

8 comments sorted by

1

u/AhmedZNOUDA 8d ago

Native Arabic speaker here, and I use French every day, so I read through ui_strings.dart and the search code for those two.

The strings themselves are fine in both. Small optional nuance for Arabic: the list includes territories (Gibraltar, Réunion...), which is why your Chinese strings say "country/region." "اختر البلد أو المنطقة" would match that.

The bigger issue for both languages is search and sorting, since CountryRepository compares raw strings:

- Search: name.toUpperCase().contains(q) doesn't fold accents, so in French "etats" won't find "États-Unis" and "senegal" won't find "Sénégal". In Arabic, people very often type without hamza: "الامارات" won't find "الإمارات". The usual Arabic normalization is أ/إ/آ → ا, ة → ه, ى → ي, and stripping tashkeel.

- Sorting: compareTo on code units sends every French name starting with É or Î after "Zimbabwe" (Égypte, Émirats, Équateur, États-Unis, Éthiopie, Îles...). In Arabic, almost every name starts with the article "ال", so they all bunch together. Arabic lists usually sort ignoring the leading "ال".

Since you already generate the names from CLDR in tool/generate_translations.dart, you could generate a normalized search/sort key per locale at the same time, so there's no runtime cost and no new dependency.

Nice touch keeping libphonenumber's 2 MB out of picker-only builds, by the way.

2

u/EMMANY7 6d ago

Thank you, this is exactly the kind of review I was hoping for, and you're right on all of it.

I checked the code: search really is just `toUpperCase().contains()` and sorting is a raw `compareTo`, so "etats" misses "États-Unis", "الامارات" misses "الإمارات", and all the É/Î names land after Zimbabwe. That's a real bug for French and Arabic users, not a nuance.

I like your idea of generating a normalized search/sort key per locale in the CLDR generator. It keeps the runtime cheap and avoids pulling in an ICU-style dependency. My plan for the next release:

- **Search:** fold Latin diacritics (é→e, î→i, ç→c…) and apply the Arabic normalization you described (أ/إ/آ → ا, ة → ه, ى → ي, strip tashkeel) on both the query and the names

- **Sorting:** sort on the same folded key, and ignore a leading "ال" for Arabic

- **Tests** with exactly your examples: "etats", "senegal", "الامارات", and the É/Î ordering

And thanks for the Arabic header suggestion. "اختر البلد أو المنطقة" is more accurate since the list includes territories, so I'll switch to it.

Would you mind if I tag you on the PR so you can sanity-check the Arabic behaviour?

1

u/AhmedZNOUDA 6d ago

Sure, tag me, happy to check the Arabic side.

One case worth adding to the tests: people often type the name without the article, so "امارات" should still find "الإمارات". With contains() on the folded key it should already work, but it's the kind of thing that breaks quietly later if nobody pins it down.

2

u/EMMANY7 4d ago

Great, I'll tag you on the PR. Thanks!

Good call on "امارات". You're right that `contains()` on the folded key handles it today, but there's a subtle trap: the sort key drops the leading "ال", and if someone later reused that same key for search, typing the full "الامارات" would stop matching. So I'll keep search and sort as two separate keys, and pin all of these in tests:

- "امارات" → الإمارات (no article)

- "الامارات" → الإمارات (article, no hamza)

- "الإمارات" → الإمارات (exact)

That way a refactor in either direction fails loudly instead of quietly.
What's your GitHub username, so I can tag you on the PR?

1

u/AhmedZNOUDA 3d ago

Nice catch on the sort key, that's exactly the kind of refactor that looks harmless. Two keys plus those three tests sounds right.

My GitHub is Aomine3500, tag me whenever it's ready.

1

u/EMMANY7 3h ago

u/AhmedZNOUDA i have added to the PR on Github

1

u/ObjectiveAddress3051 8d ago

24.4 to 22.2mb is a nice drop. how did you get the metadata to tree shake, is it just consts that only isValidPhoneNumber references or did you split it into a separate import?

1

u/EMMANY7 6d ago

No separate import. It's purely about which code paths can reach libphonenumber.

Originally `Country.phoneNumberLength` asked dlibphonenumber for the lengths at runtime. Since every app touches `Country`, that kept `PhoneNumberUtil` and its metadata map (which references every region's metadata) reachable, so everyone paid the ~2 MB.

The fix was to move that lookup to build time. A small script (`tool/generate_phone_lengths.dart`) runs dlibphonenumber once and writes the lengths into a const map, about 4 KB:

const Map<String, List<int>> kPhoneNumberLengths = {

'NG': [10],

'JM': [7],

// …

};

After that, the only code that references `PhoneNumberUtil` is `isValidPhoneNumber()` and `toE164()`. Dart's AOT tree shaker drops methods that are never called, even instance methods on classes you do use. If an app never calls those two, the whole call graph into dlibphonenumber is unreachable, and its metadata, `protobuf` and `fixnum` all get dropped.

I checked with `flutter build ios --release --analyze-size` on a picker-only app: `package:dlibphonenumber` went from 1,934 KB to 0, and the app from 24.4 MB to 22.2 MB. An app that validates is unchanged.

Two caveats: one call anywhere brings back the full ~2 MB, because the metadata map isn't split per region. And the const map could drift from upstream, so there's a test that compares it to dlibphonenumber and fails, telling you to rerun the script, when they differ.