From 4c0bc0b9ceb6730f6d2dec16fa4fe6b0f6f3f081 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:37:31 +0900 Subject: [PATCH] =?UTF-8?q?Flutter=20=EA=B8=B0=EB=B3=B8=20=EA=B8=80?= =?UTF-8?q?=EC=9D=84=20=ED=99=94=EB=A9=B4=20=EC=96=B8=EC=96=B4=EB=A1=9C=20?= =?UTF-8?q?(140=20=C2=B7=20158):=20=EB=92=A4=EB=A1=9C=20=C2=B7=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=84=A0=EC=8A=A4=20=ED=99=94=EB=A9=B4=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=C2=B7=20=EB=B6=99=EC=97=AC=EB=84=A3?= =?UTF-8?q?=EA=B8=B0=20=EB=A9=94=EB=89=B4=20=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flutter_localizations 를 더하고 MaterialApp 이 화면 언어 (i18n) 를 바꿀 때만 다시 그려진다. - Flutter 가 아는 언어면 그 언어 (앱이 더한 기계 번역 언어 포함, 예 de · vi), 모르는 언어일 때만 영어. 한국어가 섞이지 않는다. - supportedLocales 는 kMaterialSupportedLanguages 전체 + 지금 언어 (zh-Hant · pt-BR 처럼 글자 체계 · 나라까지 그대로). - Material 만 알고 Cupertino 는 모르는 언어 (ps) 는 Cupertino 글만 영어로 채운다. - 시험: locale_test (de → Zurück, 없는 언어 → Back, zh-Hant 유지, ps 열림). - Windows 캡처 시험 (windows_i18n_shot_test): 라이선스 목록을 다 읽은 뒤까지 찍는다. Co-Authored-By: Claude Opus 5.5 --- integration_test/windows_i18n_shot_test.dart | 80 +++++++++++++++++++ lib/main.dart | 58 +++++++++++++- pubspec.lock | 2 +- pubspec.yaml | 3 + test/locale_test.dart | 83 ++++++++++++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 integration_test/windows_i18n_shot_test.dart create mode 100644 test/locale_test.dart diff --git a/integration_test/windows_i18n_shot_test.dart b/integration_test/windows_i18n_shot_test.dart new file mode 100644 index 0000000..028acb7 --- /dev/null +++ b/integration_test/windows_i18n_shot_test.dart @@ -0,0 +1,80 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart' show kSecondaryButton; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/main.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/license_texts.dart'; +import 'package:path/path.dart' as p; + +/// 140 · 158 기기 감독 확인용 (Windows): 실제 앱 (JjMkvMakerApp) 에서 Flutter 기본 화면 · 버튼이 화면 언어로 나오는지 PNG (JJ_SHOT_DIR). +/// 라이선스 화면 · 글 칸의 복사 · 붙여넣기 메뉴 · 뒤로 버튼 이름. 설정은 메모리에만 (저장하지 않음). +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_i18n'); + final navKey = GlobalKey(); + final shotKey = GlobalKey(); + registerAppLicenses(); + + Future settle(WidgetTester t, [int rounds = 10]) async { + for (var i = 0; i < rounds; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(); + } + } + + Future shot(WidgetTester t, String name) async { + await settle(t, 3); + // 앱 전체 (창 · 메뉴 · 말풍선까지 - 앱을 감싼 경계) + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, '$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + for (final lang in ['ko', 'ja']) { + testWidgets('140 · 158: Flutter 기본 화면 · 버튼이 화면 언어로 ($lang)', (t) async { + t.view.physicalSize = const Size(1280, 800); + t.view.devicePixelRatio = 1; + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + await t.runAsync(() => i18n.apply(lang, save: false)); + await t.runAsync(() => t.pumpWidget(RepaintBoundary(key: shotKey, child: JjMkvMakerApp(controller: c, navigatorKey: navKey)))); + await settle(t); + // 1) 라이선스 화면 (제목 · 뒤로) + showLicensePage(context: navKey.currentContext!, applicationName: 'JJ_MKVMaker'); + await settle(t, 15); + await shot(t, 'i18n_${lang}_licenses'); + // 목록을 다 읽은 뒤 (줄마다 "라이선스 n개" 같은 개수 글이 화면 언어로) + for (var i = 0; i < 600 && find.text('aria2').evaluate().isEmpty; i++) { + await settle(t, 1); + } + await shot(t, 'i18n_${lang}_licenses_loaded'); + // 2) 뒤로 버튼 이름 (말풍선) + await t.longPress(find.byType(BackButton).first); + await settle(t, 5); + await shot(t, 'i18n_${lang}_back_tooltip'); + navKey.currentState!.pop(); + await settle(t, 5); + // 3) 글 칸의 복사 · 붙여넣기 메뉴 (오른쪽 클릭) + final ctl = TextEditingController(text: 'JJ_MKVMaker 123'); + showDialog( + context: navKey.currentContext!, + builder: (_) => AlertDialog(content: SizedBox(width: 360, child: TextField(controller: ctl))), + ); + await settle(t, 5); + await t.tapAt(t.getCenter(find.byType(TextField)), buttons: kSecondaryButton); + await settle(t, 5); + await shot(t, 'i18n_${lang}_text_menu'); + await t.runAsync(() => i18n.apply('ko', save: false)); + }); + } +} diff --git a/lib/main.dart b/lib/main.dart index d530624..bd0daf2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/cupertino.dart' show CupertinoLocalizations, DefaultCupertinoLocalizations; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; @@ -686,8 +688,47 @@ class JjMkvMakerApp extends StatelessWidget { this.onExit, }); + /// 140 · 158: Flutter 기본 글 (뒤로 · Licenses 등) 을 화면 언어로 - 앱 언어 코드 → Locale + /// 앱이 더한 언어 (기계 번역 사전) 도 Flutter 가 아는 언어면 그 언어로, 모르는 언어일 때만 영어로 (한국어가 섞이지 않게). + /// "zh-Hant" · "pt-BR" 같은 코드는 글자 체계 (4글자) · 나라 (2글자 · 숫자) 로 나눈다. + static Locale localeOf(String code) { + final parts = code.split(RegExp('[-_]')); + String? script; + String? country; + for (final s in parts.skip(1)) { + if (s.length == 4) { + script = s; + } else if (s.isNotEmpty) { + country = s; + } + } + final l = Locale.fromSubtags(languageCode: parts.first.toLowerCase(), scriptCode: script, countryCode: country?.toUpperCase()); + return GlobalMaterialLocalizations.delegate.isSupported(l) ? l : const Locale('en'); + } + + /// Flutter 기본 글 사전들. Material 은 알고 Cupertino 는 모르는 언어 (예: ps) 에서도 Cupertino 글이 비지 않게 영어로 채운다. + static const localizationsDelegates = [...GlobalMaterialLocalizations.delegates, _CupertinoFallbackDelegate()]; + + /// Flutter 가 아는 모든 언어. 지금 언어를 맨 앞에 두어 글자 체계 · 나라까지 그대로 고르게 ("zh-Hant" 가 간체로 바뀌지 않게). + static List supportedLocalesFor(Locale current) => [ + current, + for (final c in kMaterialSupportedLanguages) + if (c != current.languageCode || current.scriptCode != null || current.countryCode != null) Locale(c), + ]; + @override - Widget build(BuildContext context) => MaterialApp( + Widget build(BuildContext context) => ListenableBuilder( + // 화면 언어를 바꾸면 (i18n) Flutter 기본 글도 그 언어로 + listenable: i18n, + builder: (context, _) => _app(context), + ); + + Widget _app(BuildContext context) { + final locale = localeOf(uiLanguage); + return MaterialApp( + locale: locale, + supportedLocales: supportedLocalesFor(locale), + localizationsDelegates: localizationsDelegates, title: appTitle, debugShowCheckedModeBanner: false, theme: buildTheme(), @@ -703,6 +744,21 @@ class JjMkvMakerApp extends StatelessWidget { child: AppDropArea(c: controller, child: _SystemBarsArea(child: UiScaler(c: controller, child: child!)))), home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks), ); + } +} + +/// Cupertino 글이 없는 언어만 영어로 (GlobalCupertinoLocalizations 뒤에 두어 그쪽이 아는 언어는 그쪽이 먼저) +class _CupertinoFallbackDelegate extends LocalizationsDelegate { + const _CupertinoFallbackDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => DefaultCupertinoLocalizations.load(locale); + + @override + bool shouldReload(_CupertinoFallbackDelegate old) => false; } /// Android: 앱 화면이 상태 표시줄 · 아래쪽 작업 표시줄 밑까지 그려지므로 (Android 15 부터 기본) diff --git a/pubspec.lock b/pubspec.lock index b6aacaf..e906d88 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -338,7 +338,7 @@ packages: source: hosted version: "6.0.0" flutter_localizations: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 406d750..67243c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,9 @@ environment: dependencies: flutter: sdk: flutter + # 140 · 158: Flutter 기본 화면 · 버튼 (뒤로 · 라이선스 · 날짜 고르기 등) 을 화면 언어로 + flutter_localizations: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/test/locale_test.dart b/test/locale_test.dart new file mode 100644 index 0000000..6742d07 --- /dev/null +++ b/test/locale_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/cupertino.dart' show CupertinoLocalizations; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/main.dart'; + +void main() { + test('140 · 158: 앱 화면 언어 → Flutter 기본 글의 언어', () { + expect(JjMkvMakerApp.localeOf('ko'), const Locale('ko')); + expect(JjMkvMakerApp.localeOf('en'), const Locale('en')); + expect(JjMkvMakerApp.localeOf('ja'), const Locale('ja')); + expect(JjMkvMakerApp.localeOf('zh-Hans'), const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans')); + expect(JjMkvMakerApp.localeOf('zh-Hant'), const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant')); + expect(JjMkvMakerApp.localeOf('pt-BR'), const Locale('pt', 'BR')); + // 앱이 더한 언어 (기계 번역 사전) 도 Flutter 가 알면 그 언어로 - 한국어가 섞이지 않게 + expect(JjMkvMakerApp.localeOf('de'), const Locale('de')); + expect(JjMkvMakerApp.localeOf('vi'), const Locale('vi')); + // Flutter 가 모르는 언어만 영어 + expect(JjMkvMakerApp.localeOf('xx'), const Locale('en')); + expect(JjMkvMakerApp.localeOf('tlh'), const Locale('en')); + }); + + Future backTooltip(WidgetTester tester, String code) async { + final locale = JjMkvMakerApp.localeOf(code); + late String text; + await tester.pumpWidget(MaterialApp( + key: ValueKey(code), + locale: locale, + supportedLocales: JjMkvMakerApp.supportedLocalesFor(locale), + localizationsDelegates: JjMkvMakerApp.localizationsDelegates, + home: Builder(builder: (context) { + text = MaterialLocalizations.of(context).backButtonTooltip; + return const SizedBox(); + }), + )); + await tester.pumpAndSettle(); + return text; + } + + testWidgets('140 · 158: 뒤로 버튼 이름이 화면 언어로 (모르는 언어만 영어)', (tester) async { + expect(await backTooltip(tester, 'ko'), '뒤로'); + expect(await backTooltip(tester, 'de'), 'Zurück'); + expect(await backTooltip(tester, 'xx'), 'Back'); + }); + + testWidgets('140 · 158: Material 만 아는 언어 (Cupertino 글 없음) 도 열림 - Cupertino 글은 영어', (tester) async { + final only = kMaterialSupportedLanguages.where((c) => !GlobalCupertinoLocalizations.delegate.isSupported(Locale(c))).toList(); + for (final c in only.isEmpty ? ['ko'] : only) { + final locale = JjMkvMakerApp.localeOf(c); + expect(locale, Locale(c)); + late String cup; + await tester.pumpWidget(MaterialApp( + key: ValueKey(c), + locale: locale, + supportedLocales: JjMkvMakerApp.supportedLocalesFor(locale), + localizationsDelegates: JjMkvMakerApp.localizationsDelegates, + home: Builder(builder: (context) { + cup = CupertinoLocalizations.of(context).cutButtonLabel; + return const SizedBox(); + }), + )); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: c); + expect(cup, isNotEmpty, reason: c); + } + }); + + testWidgets('140 · 158: 번체 중국어는 간체로 바뀌지 않음 (글자 체계까지 고름)', (tester) async { + late Locale got; + final locale = JjMkvMakerApp.localeOf('zh-Hant'); + await tester.pumpWidget(MaterialApp( + locale: locale, + supportedLocales: JjMkvMakerApp.supportedLocalesFor(locale), + localizationsDelegates: JjMkvMakerApp.localizationsDelegates, + home: Builder(builder: (context) { + got = Localizations.localeOf(context); + return const SizedBox(); + }), + )); + await tester.pumpAndSettle(); + expect(got.scriptCode, 'Hant'); + }); +}