From b02862591c1927aedb47d9dc8af3ab03bf2eb836 Mon Sep 17 00:00:00 2001 From: veto9292 Date: Mon, 23 Feb 2026 12:46:52 +0330 Subject: [PATCH] feat: implement region detection based on timezone and locale --- lib/features/intro/widget/intro_page.dart | 186 +++++++++++++++++++++- 1 file changed, 182 insertions(+), 4 deletions(-) diff --git a/lib/features/intro/widget/intro_page.dart b/lib/features/intro/widget/intro_page.dart index 53dc8254..568a215f 100644 --- a/lib/features/intro/widget/intro_page.dart +++ b/lib/features/intro/widget/intro_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -17,7 +18,6 @@ import 'package:hiddify/features/settings/data/config_option_repository.dart'; import 'package:hiddify/gen/assets.gen.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:timezone_to_country/timezone_to_country.dart'; class IntroPage extends HookConsumerWidget with PresLogger { const IntroPage({super.key}); @@ -182,7 +182,7 @@ class IntroPage extends HookConsumerWidget with PresLogger { Future autoSelectRegion(WidgetRef ref) async { try { - final countryCode = await TimeZoneToCountry.getLocalCountryCode(); + final countryCode = RegionDetector.detect(); final regionLocale = _getRegionLocale(countryCode); loggy.debug('Timezone Region: ${regionLocale.region} Locale: ${regionLocale.locale}'); await ref.read(ConfigOptions.region.notifier).update(regionLocale.region); @@ -227,9 +227,9 @@ class IntroPage extends HookConsumerWidget with PresLogger { case "AF": return RegionLocale(Region.af, AppLocale.fa); case "BR": - return RegionLocale(Region.other, AppLocale.ptBr); + return RegionLocale(Region.br, AppLocale.ptBr); case "TR": - return RegionLocale(Region.other, AppLocale.tr); + return RegionLocale(Region.tr, AppLocale.tr); default: return RegionLocale(Region.other, AppLocale.en); } @@ -242,3 +242,181 @@ class RegionLocale { RegionLocale(this.region, this.locale); } + +class RegionDetector { + /// Returns: 'IR' | 'AF' | 'CN' | 'TR' | 'RU' | 'BR' | 'US' + static String detect() { + final now = DateTime.now(); + final offset = now.timeZoneOffset.inMinutes; + final tz = now.timeZoneName.toLowerCase().trim(); + + if (offset == 210) return 'IR'; + + if (offset == 270) { + final (_, country) = _parseLocale(); + return country == 'IR' ? 'IR' : 'AF'; + } + + final fromName = _fromTzName(tz, offset); + if (fromName != null) return fromName; + + final candidates = _candidatesForOffset(offset); + if (candidates.isEmpty) return 'US'; + + return _resolveByLocale(candidates); + } + + static String? _fromTzName(String tz, int offset) { + if (tz.contains('/')) { + final city = tz.split('/').last.replaceAll(' ', '_'); + final r = _ianaCities[city]; + if (r != null) return r; + } + + if (tz == 'irst' || tz == 'irdt' || tz.contains('iran')) return 'IR'; + + if (tz == 'aft' || tz.contains('afghanistan')) return 'AF'; + + if (tz == 'trt' || tz.contains('turkey') || tz.contains('istanbul')) { + return 'TR'; + } + + if (tz.contains('china') || tz.contains('beijing')) return 'CN'; + if (tz == 'cst' && offset == 480) return 'CN'; + + if (_matchesRussiaTz(tz)) return 'RU'; + + if (_matchesBrazilTz(tz)) return 'BR'; + + return null; + } + + static bool _matchesRussiaTz(String tz) { + if (tz.contains('russia') || tz.contains('moscow')) return true; + + const abbrs = {'msk', 'yekt', 'omst', 'krat', 'irkt', 'yakt', 'vlat', 'magt', 'pett', 'sakt', 'sret'}; + if (abbrs.contains(tz)) return true; + + const winKeys = [ + 'ekaterinburg', + 'kaliningrad', + 'yakutsk', + 'vladivostok', + 'magadan', + 'sakhalin', + 'kamchatka', + 'astrakhan', + 'saratov', + 'volgograd', + 'altai', + 'tomsk', + 'transbaikal', + 'n. central asia', + 'north asia', + ]; + return winKeys.any(tz.contains); + } + + static bool _matchesBrazilTz(String tz) { + if (tz == 'brt' || tz == 'brst') return true; + if (tz.contains('brazil') || tz.contains('brasilia')) return true; + + const winKeys = ['e. south america', 'central brazilian', 'tocantins', 'bahia']; + return winKeys.any(tz.contains); + } + + static Set _candidatesForOffset(int offset) { + final c = {}; + + if (offset == 180) c.add('TR'); + + if (offset == 480) c.add('CN'); + + if (_ruOffsets.contains(offset)) c.add('RU'); + + if (_brOffsets.contains(offset)) c.add('BR'); + + return c; + } + + static const _ruOffsets = {120, 180, 240, 300, 360, 420, 480, 540, 600, 660, 720}; + + static const _brOffsets = {-120, -180, -240, -300}; + + static String _resolveByLocale(Set candidates) { + final (lang, country) = _parseLocale(); + + if (country != null && candidates.contains(country)) { + return country; + } + + final regionFromLang = _langToRegion[lang]; + if (regionFromLang != null && candidates.contains(regionFromLang)) { + return regionFromLang; + } + + return 'US'; + } + + static (String, String?) _parseLocale() { + try { + final parts = Platform.localeName.split(RegExp(r'[_\-.]')); + final lang = parts.first.toLowerCase(); + + String? country; + for (final p in parts.skip(1)) { + if (p.length == 2) { + country = p.toUpperCase(); + break; + } + } + + return (lang, country); + } catch (_) { + return ('en', null); + } + } + + static const _langToRegion = {'fa': 'IR', 'ps': 'AF', 'tr': 'TR', 'zh': 'CN', 'ru': 'RU', 'pt': 'BR'}; + + static const _ianaCities = { + 'tehran': 'IR', + 'kabul': 'AF', + 'istanbul': 'TR', + 'shanghai': 'CN', + 'chongqing': 'CN', + 'urumqi': 'CN', + 'harbin': 'CN', + 'moscow': 'RU', + 'kaliningrad': 'RU', + 'samara': 'RU', + 'yekaterinburg': 'RU', + 'omsk': 'RU', + 'novosibirsk': 'RU', + 'barnaul': 'RU', + 'tomsk': 'RU', + 'krasnoyarsk': 'RU', + 'irkutsk': 'RU', + 'chita': 'RU', + 'yakutsk': 'RU', + 'vladivostok': 'RU', + 'magadan': 'RU', + 'sakhalin': 'RU', + 'kamchatka': 'RU', + 'anadyr': 'RU', + 'volgograd': 'RU', + 'saratov': 'RU', + 'astrakhan': 'RU', + 'sao_paulo': 'BR', + 'fortaleza': 'BR', + 'recife': 'BR', + 'manaus': 'BR', + 'belem': 'BR', + 'cuiaba': 'BR', + 'bahia': 'BR', + 'rio_branco': 'BR', + 'noronha': 'BR', + 'porto_velho': 'BR', + 'campo_grande': 'BR', + }; +}