import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; import 'package:path/path.dart' as p; import '../core/languages.dart'; import '../l10n/tr.dart'; import '../services/ai_services.dart'; import '../services/media_tool.dart' show MediaToolException; import '../services/model_store.dart'; import 'app_controller.dart'; /// 화면 언어 (환경 설정 > 화면 언어). /// /// - 기본 언어 (한국어 · English · 日本語 · 简体中文) 는 프로그램에 사전이 들어 있다 (assets/l10n). /// - 그 밖의 언어는 [addLanguage] 로 더할 수 있다: 이 기기의 AI 번역 모델 (NLLB) 로 화면 글자 전체를 /// 영어 사전에서 자동 번역해 설정 폴더의 l10n/<코드>.json 에 저장한다. 더한 언어는 지울 수 있다. /// - 언어를 바꾸면 화면 전체를 그 자리에서 다시 그린다 (보던 화면 · 웹 페이지는 그대로). class I18nController extends ChangeNotifier { /// 프로그램에 들어 있는 언어 (지울 수 없음) static const builtIn = ['ko', 'en', 'ja', 'zh-Hans']; /// 언어 고르기에 보일 이름 (그 언어로 쓴 이름. 읽을 수 없는 언어를 골라도 되돌릴 수 있게) static const nativeNames = { 'ko': '한국어', 'en': 'English', 'ja': '日本語', 'zh-Hans': '简体中文', 'zh-Hant': '繁體中文', 'es': 'Español', 'fr': 'Français', 'de': 'Deutsch', 'ru': 'Русский', 'pt': 'Português', 'it': 'Italiano', 'vi': 'Tiếng Việt', 'th': 'ไทย', 'id': 'Bahasa Indonesia', 'ms': 'Bahasa Melayu', 'tl': 'Tagalog', 'ar': 'العربية', 'hi': 'हिन्दी', 'tr': 'Türkçe', 'pl': 'Polski', 'nl': 'Nederlands', 'uk': 'Українська', 'sv': 'Svenska', 'cs': 'Čeština', 'el': 'Ελληνικά', 'he': 'עברית', 'fi': 'Suomi', 'da': 'Dansk', 'no': 'Norsk', 'hu': 'Magyar', 'ro': 'Română', 'fa': 'فارسی', 'bn': 'বাংলা', 'ur': 'اردو', 'ta': 'தமிழ்', 'ne': 'नेपाली', 'mn': 'Монгол', 'km': 'ខ្មែរ', 'lo': 'ລາວ', 'my': 'မြန်မာ', }; static String nativeName(String code) => nativeNames[code] ?? code; /// 앱이 시작할 때 [init] 으로 연결 (그 전에는 기본 언어만) AppController? _app; AppController get _c => _app!; /// 더한 언어의 사전 폴더 (설정 폴더/l10n) String _dir = ''; /// 사전 읽기 (기본 언어). 테스트에서 바꿔 끼운다. Future Function(String asset) loadAsset = rootBundle.loadString; void init(AppController c, String dataDir) { _app = c; _dir = p.join(dataDir, 'l10n'); } /// 고를 수 있는 언어: 기본 + 더한 것 List get available => [...builtIn, ...?_app?.settings.display.languagesAdded]; /// 더할 수 있는 언어 (AI 번역 모델이 아는 언어 중 아직 없는 것) List get addable => [ for (final l in languages) if (l.nllb.isNotEmpty && !available.contains(l.code)) l, ]; /// 더한 언어를 자동 번역할 수 있는지 (AI 번역이 되는 기기) bool get canTranslate => _app?.services.createTranslator != null; Future> _read(String code) async { try { final text = builtIn.contains(code) ? await loadAsset('assets/l10n/$code.json') : await File(p.join(_dir, '$code.json')).readAsString(); return Map.from(jsonDecode(text) as Map); } catch (_) { return const {}; } } /// "시스템 언어 따르기" 를 고른 값 (58 · 102: 처음 설치의 기본) static const system = 'system'; /// 기기 (Windows · Android) 의 언어 → 들어 있는 언어 (한국어 · 日本語 · 简体中文, 그 밖의 언어는 English) static String systemCode([String? localeName]) { final l = (localeName ?? Platform.localeName).toLowerCase(); if (l.startsWith('ko')) return 'ko'; if (l.startsWith('ja')) return 'ja'; if (l.startsWith('zh')) return 'zh-Hans'; return 'en'; } /// 설정에서 고른 값 ('system' 이면 시스템 언어 따르기) String get chosen => _app?.settings.display.language ?? uiLanguage; /// 화면 언어 바꾸기 (설정에 저장하고 화면 전체를 다시 그린다). [system] 이면 기기 언어를 따른다 Future apply(String code, {bool save = true}) async { final want = code; if (code == system) code = systemCode(); if (code != 'ko' && !available.contains(code)) code = 'ko'; final dict = code == 'ko' ? const {} : await _read(code); final fallback = code == 'ko' || code == 'en' ? const {} : await _read('en'); setTranslations(code, dict, fallback); if (save && _app != null && _c.settings.display.language != want) { await _c.updateSettings((x) => x.display.language = want); } rebuildAll(); notifyListeners(); } /// 화면의 모든 위젯을 다시 그린다 (상태는 그대로) static void rebuildAll() { void visit(Element e) { e.markNeedsBuild(); e.visitChildren(visit); } WidgetsBinding.instance.rootElement?.visitChildren(visit); } bool _cancel = false; void cancel() => _cancel = true; /// 언어 더하기: 영어 사전을 AI 번역 모델로 그 언어로 번역해 저장한다 (모델이 없으면 먼저 내려받음). /// [onProgress] (단계 설명, 0.0~1.0). 취소하면 [AiCancelled]. Future addLanguage(Language l, {void Function(String phase, double progress)? onProgress}) async { if (!canTranslate) throw MediaToolException(tr('이 기기에서는 AI 번역을 쓸 수 없습니다.')); _cancel = false; final models = _c.services.models; if (!await models.isInstalled(nllbModel)) { await models.download(nllbModel, isCancelled: () => _cancel, onProgress: (x) => onProgress?.call(trf('번역 모델 내려받는 중 {0}%', [(x * 100).round()]), x * 0.3)); } if (_cancel) throw const AiCancelled(); onProgress?.call(tr('번역 모델 불러오는 중'), 0.3); final translator = _c.services.createTranslator!(); try { await translator.load(await models.folderOf(nllbModel)); final en = await _read('en'); final dict = await translateDictionary( en, (lines, onLine) => translator.translate(lines, source: 'eng_Latn', target: l.nllb, onProgress: (x) { if (_cancel) translator.cancel(); onLine(x); }), onProgress: (x) => onProgress?.call(trf('{0} 로 번역 중 {1}%', [nativeName(l.code), (x * 100).round()]), 0.35 + 0.65 * x), ); if (_cancel) throw const AiCancelled(); await Directory(_dir).create(recursive: true); await File(p.join(_dir, '${l.code}.json')) .writeAsString(const JsonEncoder.withIndent(' ').convert(dict)); await _c.updateSettings((x) => x.display.languagesAdded = [...x.display.languagesAdded, l.code]); } finally { await translator.dispose(); } notifyListeners(); } /// 더한 언어 지우기 (지금 쓰는 언어면 한국어로) Future removeLanguage(String code) async { if (builtIn.contains(code)) return; try { await File(p.join(_dir, '$code.json')).delete(); } catch (_) {} await _c.updateSettings((x) => x.display.languagesAdded = [...x.display.languagesAdded]..remove(code)); if (uiLanguage == code) { await apply('ko'); } else { notifyListeners(); } } } /// 영어 사전 (한국어 원문 → 영어) 을 다른 언어로 번역한 사전 (한국어 원문 → 그 언어). /// 여러 줄은 줄마다 번역하고, 자리 표시 ({0} …) 가 사라진 번역은 영어 그대로 둔다. Future> translateDictionary( Map en, Future> Function(List lines, void Function(double) onProgress) translate, { void Function(double progress)? onProgress, }) async { final keys = en.keys.toList(); final lines = []; final spans = <(int, int)>[]; for (final k in keys) { final parts = en[k]!.split('\n'); spans.add((lines.length, parts.length)); lines.addAll(parts); } // 빈 줄은 번역기에 넘기지 않는다 final todo = [for (var i = 0; i < lines.length; i++) if (lines[i].trim().isNotEmpty) i]; final out = List.of(lines); final got = await translate([for (final i in todo) lines[i]], (x) => onProgress?.call(x)); for (var j = 0; j < todo.length && j < got.length; j++) { out[todo[j]] = got[j]; } final slot = RegExp(r'\{\d+\}'); final dict = {}; for (var i = 0; i < keys.length; i++) { final (start, n) = spans[i]; final text = out.sublist(start, start + n).join('\n'); final want = slot.allMatches(en[keys[i]]!).map((m) => m.group(0)).toSet(); final have = slot.allMatches(text).map((m) => m.group(0)).toSet(); dict[keys[i]] = want.length == have.length && have.containsAll(want) ? text : en[keys[i]]!; } return dict; } /// 앱 전체에서 쓰는 화면 언어 관리 final i18n = I18nController();