feat: implement region detection based on timezone and locale

This commit is contained in:
veto9292 2026-02-23 12:46:52 +03:30
parent 714b78bd05
commit b02862591c

View File

@ -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<void> 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<String> _candidatesForOffset(int offset) {
final c = <String>{};
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<String> 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 = <String, String>{'fa': 'IR', 'ps': 'AF', 'tr': 'TR', 'zh': 'CN', 'ru': 'RU', 'pt': 'BR'};
static const _ianaCities = <String, String>{
'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',
};
}