import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../l10n/app_localizations.dart'; import '../../l10n/remote_app_localizations.dart'; export '../../l10n/app_localizations.dart' show AppLocalizations; /// 1차 공식 지원 언어 — 정책 원장 **B-76** 확정값 `['ko','en','ja','zh-CN','es']`. /// /// 순서는 언어 설정 시트(명세 §2.95 `554:24126` 「소팅은 UI대로 유지」)와 같다. /// **zh-TW 확장**: `lib/l10n/app_zh_TW.arb` 를 추가하고 여기에 `Locale('zh', 'TW')` 한 줄과 /// [languageName] 한 줄을 더하면 끝난다. 그 전까지 zh-TW 단말은 [resolveAppLocale] 이 /// zh-CN 으로 폴백한다. const List appLocales = [ Locale('ko'), Locale('en'), Locale('ja'), Locale('zh', 'CN'), Locale('es'), ]; /// 기준 언어(arb template). 미번역 문구는 여기로 떨어진다(FR-I18N-01). const Locale fallbackLocale = Locale('ko'); /// 지원 5종에 없는 단말 언어의 기본값. 🟡 발주사 회신 전 제안값 — 한국어를 모르는 /// 해외 단말에 한국어를 띄우지 않는다. const Locale deviceFallbackLocale = Locale('en'); /// 언어 설정 시트 표기 — 명세 §2.95 원문(자기 언어로 적는다. 번역하지 않는다). String languageName(Locale l) => switch (l.languageCode) { 'ko' => '한국어', 'en' => 'English', 'ja' => '日本語', 'zh' => '中文', 'es' => 'Español', _ => l.toLanguageTag(), }; /// 단말 언어 목록 → 지원 5종 중 하나(B-76 「기동 시 단말 국가/언어 조회 → 지원 5종 내 매칭」). /// /// 언어 코드로 맞춘다. zh 는 지역·문자 체계(zh-TW · zh-Hant)와 무관하게 zh-CN 으로 /// 간다(🟡 회신 전 제안값). 어느 것도 맞지 않으면 [deviceFallbackLocale](en). Locale resolveAppLocale(Iterable device) { for (final d in device) { for (final l in appLocales) { if (l.languageCode == d.languageCode) return l; } } return deviceFallbackLocale; } /// `users/{uid}.locale` · `i18n/{locale}` 문서 ID 에 쓰는 태그. zh-CN 은 하이픈이다. String localeTag(Locale l) => l.toLanguageTag(); Locale? parseLocaleTag(String? tag) { if (tag == null) return null; for (final l in appLocales) { if (localeTag(l) == tag) return l; } return null; } /// 앱 표시 언어 · 원격 문자열 상태. /// /// - 고른 언어는 기기(SharedPreferences)에 남기고, 로그인 상태면 [onSaved] 로 /// `users/{uid}.locale` 에도 쓴다(설계서 §2.1). /// - 원격 문자열은 [remoteSource] 가 주는 `i18n/{locale}` 문서 필드(키 → 문자열)다. /// 문서가 없거나 읽기가 실패하면 번들 문자열 그대로다. class LocaleController extends ChangeNotifier { LocaleController({ Locale? initial, this.remoteSource, this.onSaved, List? deviceLocales, }) : _locale = initial ?? resolveAppLocale( deviceLocales ?? WidgetsBinding.instance.platformDispatcher.locales, ) { _watchRemote(); } static const String prefsKey = 'app.locale'; /// 원격 문자열 구독. null 이면 번들만 쓴다(목 모드 · 테스트). final Stream> Function(String tag)? remoteSource; /// 사용자가 언어를 바꿨을 때 서버 반영. 실패는 삼킨다 — 화면 언어는 이미 바뀌었다. final Future Function(String tag)? onSaved; Locale _locale; Locale get locale => _locale; Map _remote = const {}; Map get remote => _remote; StreamSubscription>? _sub; /// 기기에 저장된 선택을 불러온다. 없으면 단말 언어 매칭값을 유지한다. Future restore() async { try { final prefs = await SharedPreferences.getInstance(); final saved = parseLocaleTag(prefs.getString(prefsKey)); if (saved != null) _apply(saved); } catch (e) { debugPrint('[LocaleController] restore failed: ${e.runtimeType}'); } } Future select(Locale next) async { if (!appLocales.contains(next)) return; _apply(next); final tag = localeTag(next); try { final prefs = await SharedPreferences.getInstance(); await prefs.setString(prefsKey, tag); } catch (e) { debugPrint('[LocaleController] save failed: ${e.runtimeType}'); } try { await onSaved?.call(tag); } catch (e) { debugPrint('[LocaleController] profile save failed: ${e.runtimeType}'); } } /// 로그인 직후 `users/{uid}.locale` 을 받는다. 이 기기에서 고른 언어가 있으면 그쪽이 이긴다. /// 서버로 다시 쓰지 않는다(읽은 값 그대로). Future adoptProfile(String? tag) async { final profile = parseLocaleTag(tag); if (profile == null) return; try { // 기기 저장값을 직접 본다 — [restore] 가 아직 안 끝났어도 판정이 맞다. final prefs = await SharedPreferences.getInstance(); if (prefs.getString(prefsKey) != null) return; await prefs.setString(prefsKey, localeTag(profile)); } catch (e) { debugPrint('[LocaleController] profile adopt failed: ${e.runtimeType}'); return; } _apply(profile); } /// 언어를 바꾼다. 이전 언어의 원격 문자열은 버린다 — 새 언어 문서가 오기 전까지 번들만 쓴다. void _apply(Locale next) { if (next == _locale) return; _locale = next; _remote = const {}; _watchRemote(); notifyListeners(); } void _watchRemote() { unawaited(_sub?.cancel()); _sub = null; final source = remoteSource; if (source == null) return; _sub = source(localeTag(_locale)).listen( (data) { // 문자열 값만 받는다. 운영자가 숫자나 맵을 넣어도 화면이 깨지지 않게. _remote = { for (final e in data.entries) if (e.value is String && (e.value as String).isNotEmpty) e.key: e.value as String, }; notifyListeners(); }, onError: (Object e) => debugPrint('[LocaleController] remote: ${e.runtimeType}'), ); } @override void dispose() { unawaited(_sub?.cancel()); super.dispose(); } } /// 설정 화면처럼 트리 아래에서 언어를 바꿔야 하는 곳이 [LocaleController] 를 찾는 통로. class LocaleScope extends InheritedNotifier { const LocaleScope({ super.key, required LocaleController controller, required super.child, }) : super(notifier: controller); static LocaleController? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType()?.notifier; } /// 번들 + 원격 문자열 대리자. 원격 맵이 바뀌면 다시 읽는다. class RemoteL10nDelegate extends LocalizationsDelegate { const RemoteL10nDelegate(this.remote); final Map remote; @override bool isSupported(Locale locale) => AppLocalizations.delegate.isSupported(locale); @override Future load(Locale locale) => SynchronousFuture( appL10n = RemoteAppLocalizations(lookupAppLocalizations(locale), remote), ); @override bool shouldReload(RemoteL10nDelegate old) => !identical(old.remote, remote); } /// 기준 언어(ko) 문자열. `BuildContext` 가 없는 곳의 기본값으로 쓴다. final AppLocalizations koL10n = lookupAppLocalizations(fallbackLocale); /// 지금 앱 언어 문자열 — `BuildContext` 가 없는 데이터층(저장소 오류 문구 · 상태 라벨)이 쓴다. /// [RemoteL10nDelegate] 가 언어를 불러올 때마다 바뀐다. 대리자가 없는 트리(단독 위젯 테스트)는 ko. /// 화면 코드는 이것 대신 [L10nContext.l10n] 을 쓴다 — 언어가 바뀌면 다시 그려진다. AppLocalizations appL10n = koL10n; extension L10nContext on BuildContext { /// 현재 언어 문자열. 대리자가 없는 트리(단독 위젯 테스트 등)는 ko 를 쓴다. AppLocalizations get l10n => AppLocalizations.of(this) ?? koL10n; }