앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user