240 lines
9.1 KiB
Dart
240 lines
9.1 KiB
Dart
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<String> Function(String asset) loadAsset = rootBundle.loadString;
|
|
|
|
void init(AppController c, String dataDir) {
|
|
_app = c;
|
|
_dir = p.join(dataDir, 'l10n');
|
|
}
|
|
|
|
/// 고를 수 있는 언어: 기본 + 더한 것
|
|
List<String> get available => [...builtIn, ...?_app?.settings.display.languagesAdded];
|
|
|
|
/// 더할 수 있는 언어 (AI 번역 모델이 아는 언어 중 아직 없는 것)
|
|
List<Language> 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<Map<String, String>> _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<String, String>.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<void> 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 <String, String>{} : await _read(code);
|
|
final fallback = code == 'ko' || code == 'en' ? const <String, String>{} : 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<void> 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<void> 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<Map<String, String>> translateDictionary(
|
|
Map<String, String> en,
|
|
Future<List<String>> Function(List<String> lines, void Function(double) onProgress) translate, {
|
|
void Function(double progress)? onProgress,
|
|
}) async {
|
|
final keys = en.keys.toList();
|
|
final lines = <String>[];
|
|
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<String>.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 = <String, String>{};
|
|
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();
|