Files
fanuzu/app/lib/core/i18n/app_locale.dart
T

228 lines
8.1 KiB
Dart

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<Locale> 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<Locale> 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<Locale>? deviceLocales,
}) : _locale =
initial ??
resolveAppLocale(
deviceLocales ??
WidgetsBinding.instance.platformDispatcher.locales,
) {
_watchRemote();
}
static const String prefsKey = 'app.locale';
/// 원격 문자열 구독. null 이면 번들만 쓴다(목 모드 · 테스트).
final Stream<Map<String, Object?>> Function(String tag)? remoteSource;
/// 사용자가 언어를 바꿨을 때 서버 반영. 실패는 삼킨다 — 화면 언어는 이미 바뀌었다.
final Future<void> Function(String tag)? onSaved;
Locale _locale;
Locale get locale => _locale;
Map<String, String> _remote = const {};
Map<String, String> get remote => _remote;
StreamSubscription<Map<String, Object?>>? _sub;
/// 기기에 저장된 선택을 불러온다. 없으면 단말 언어 매칭값을 유지한다.
Future<void> 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<void> 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<void> 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<LocaleController> {
const LocaleScope({
super.key,
required LocaleController controller,
required super.child,
}) : super(notifier: controller);
static LocaleController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<LocaleScope>()?.notifier;
}
/// 번들 + 원격 문자열 대리자. 원격 맵이 바뀌면 다시 읽는다.
class RemoteL10nDelegate extends LocalizationsDelegate<AppLocalizations> {
const RemoteL10nDelegate(this.remote);
final Map<String, String> remote;
@override
bool isSupported(Locale locale) =>
AppLocalizations.delegate.isSupported(locale);
@override
Future<AppLocalizations> 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;
}