Flutter 기본 글을 화면 언어로 (140 · 158): 뒤로 · 라이선스 화면 · 복사 · 붙여넣기 메뉴 등

- 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 <noreply@anthropic.com>
묶음4
RyuJongJin 2026-10-09 23:37:31 +09:00
parent f331672e3a
commit 4c0bc0b9ce
5 changed files with 224 additions and 2 deletions

View File

@ -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<NavigatorState>();
final shotKey = GlobalKey();
registerAppLicenses();
Future<void> settle(WidgetTester t, [int rounds = 10]) async {
for (var i = 0; i < rounds; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await t.pump();
}
}
Future<void> 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<void>(
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));
});
}
}

View File

@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/cupertino.dart' show CupertinoLocalizations, DefaultCupertinoLocalizations;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -686,8 +688,47 @@ class JjMkvMakerApp extends StatelessWidget {
this.onExit, 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<Locale> supportedLocalesFor(Locale current) => [
current,
for (final c in kMaterialSupportedLanguages)
if (c != current.languageCode || current.scriptCode != null || current.countryCode != null) Locale(c),
];
@override @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, title: appTitle,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildTheme(), theme: buildTheme(),
@ -704,6 +745,21 @@ class JjMkvMakerApp extends StatelessWidget {
home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks), home: HomePage(c: controller, downloads: downloads, onExit: onExit, bookmarks: bookmarks),
); );
} }
}
/// Cupertino 글이 없는 언어만 영어로 (GlobalCupertinoLocalizations 뒤에 두어 그쪽이 아는 언어는 그쪽이 먼저)
class _CupertinoFallbackDelegate extends LocalizationsDelegate<CupertinoLocalizations> {
const _CupertinoFallbackDelegate();
@override
bool isSupported(Locale locale) => true;
@override
Future<CupertinoLocalizations> load(Locale locale) => DefaultCupertinoLocalizations.load(locale);
@override
bool shouldReload(_CupertinoFallbackDelegate old) => false;
}
/// Android: 앱 화면이 상태 표시줄 · 아래쪽 작업 표시줄 밑까지 그려지므로 (Android 15 부터 기본) /// Android: 앱 화면이 상태 표시줄 · 아래쪽 작업 표시줄 밑까지 그려지므로 (Android 15 부터 기본)
/// 위쪽 버튼 줄이 시계 · 배터리 표시와 겹치지 않게 그만큼 띄운다. 전체 화면 재생 중에는 표시줄이 숨어 여백도 없다. /// 위쪽 버튼 줄이 시계 · 배터리 표시와 겹치지 않게 그만큼 띄운다. 전체 화면 재생 중에는 표시줄이 숨어 여백도 없다.

View File

@ -338,7 +338,7 @@ packages:
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_localizations: flutter_localizations:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"

View File

@ -30,6 +30,9 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# 140 · 158: Flutter 기본 화면 · 버튼 (뒤로 · 라이선스 · 날짜 고르기 등) 을 화면 언어로
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.

83
test/locale_test.dart Normal file
View File

@ -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<String> 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');
});
}