From 70e607199187838ca72ec111afce8b1ca6072818 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:28:11 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=B2=84=ED=8A=BC=EC=97=90=20Tal?= =?UTF-8?q?kBack=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20(241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 접근성 (기기 확인): 바깥 Tooltip 으로 감싼 아이콘 버튼은 Android 에 이름이 가지 않았다 - 버튼 자체의 tooltip, 탐색기 펼치기 화살표 이름, 설정 줄 MergeSemantics - a11y_names_test (다섯 화면 · 세 폭) 로 찾은 넘침도 고침: 모니터링 디스크 줄, MKV 인코딩 줄 (960 보다 좁으면 줄 바꿈 - 내 판단, 관리자 승인) Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 + assets/l10n/ja.json | 2 + assets/l10n/zh-Hans.json | 2 + lib/ui/app_actions.dart | 46 +++++++++++++++++---- lib/ui/explorer_page.dart | 17 +++++--- lib/ui/home_page.dart | 31 ++++++++------ lib/ui/monitor_page.dart | 6 ++- lib/ui/setting_tile.dart | 7 ++++ test/a11y_names_test.dart | 85 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 test/a11y_names_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 54ccedd..3364079 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -352,10 +352,12 @@ "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.", "{0} 전체": "All of {0}", + "{0} 접기": "Collapse {0}", "{0} 제거": "Remove {0}", "{0} 추가": "Add {0}", "{0} 파일": "{0} file", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)", + "{0} 펼치기": "Expand {0}", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "Folder {0} is missing or can't be opened, so it was removed from history", "{0}. {1} (대기)": "{0}. {1} (waiting)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 1688616..8fccf17 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -352,10 +352,12 @@ "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 전체": "{0} 全体", + "{0} 접기": "{0} を折りたたむ", "{0} 제거": "{0} の削除", "{0} 추가": "{0} を追加", "{0} 파일": "{0} ファイル", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)", + "{0} 펼치기": "{0} を展開", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "{0} フォルダーが存在しないか開けないため、履歴から削除しました", "{0}. {1} (대기)": "{0}. {1}(待機)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 7e2da39..57960e7 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -352,10 +352,12 @@ "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 전체": "{0} 全部", + "{0} 접기": "折叠 {0}", "{0} 제거": "移除 {0}", "{0} 추가": "添加 {0}", "{0} 파일": "{0} 文件", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)", + "{0} 펼치기": "展开 {0}", "{0} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "文件夹 {0} 不存在或无法打开,已从历史记录中移除", "{0}. {1} (대기)": "{0}. {1}(等待)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}", diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index bf2728e..d48595f 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -74,14 +74,23 @@ class _AppTopBarState extends State { height: appBarHeight, color: JjColors.panel, padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - // 242: middle 이 넘쳐도 오른쪽 (화면 크기 "100%" · 설정 · 종료) 을 덮어 그리지 않게 - child: Row(children: [ - nav, - const SizedBox(width: 8), - Expanded(child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), - if (widget.trailing != null) widget.trailing!, - actions, - ]), + // 242: middle 이 넘쳐도 오른쪽 (화면 크기 "100%" · 설정 · 종료) 을 덮어 그리지 않게. + // trailing 이 있으면 제 폭대로 재고 남는 폭을 middle 에 (모자라면 [_check] 가 두 줄로 - 그사이 넘침 오류 없이 잘라 그림) + child: widget.trailing == null + ? Row(children: [ + nav, + const SizedBox(width: 8), + Expanded(child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), + actions, + ]) + : ClipRect( + child: CustomMultiChildLayout(delegate: _BarLayout(), children: [ + LayoutId(id: #nav, child: nav), + LayoutId(id: #mid, child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), + LayoutId(id: #trail, child: widget.trailing!), + LayoutId(id: #act, child: actions), + ]), + ), ); } return Container( @@ -106,6 +115,27 @@ class _AppTopBarState extends State { } } +/// 242: [nav] [middle (남는 폭)] [trailing] [actions] - nav · trailing · actions 는 제 폭대로, 세로 가운데 +class _BarLayout extends MultiChildLayoutDelegate { + @override + void performLayout(Size size) { + final loose = BoxConstraints(maxHeight: size.height); + final nav = layoutChild(#nav, loose); + final act = layoutChild(#act, loose); + final trail = layoutChild(#trail, loose); + final midW = (size.width - nav.width - 8 - trail.width - act.width).clamp(0.0, double.infinity); + final mid = layoutChild(#mid, BoxConstraints(minWidth: midW, maxWidth: midW, maxHeight: size.height)); + double y(Size s) => (size.height - s.height) / 2; + positionChild(#nav, Offset(0, y(nav))); + positionChild(#mid, Offset(nav.width + 8, y(mid))); + positionChild(#trail, Offset(nav.width + 8 + midW, y(trail))); + positionChild(#act, Offset(size.width - act.width, y(act))); + } + + @override + bool shouldRelayout(_BarLayout oldDelegate) => false; +} + /// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다 class AppScope extends InheritedWidget { final AppController controller; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index c0665dd..dc157ae 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3367,12 +3367,19 @@ class _ExplorerPageState extends State { ? (loading ? const Center( child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) - : InkResponse( - radius: chevronWidth / 2 + 4, - containedInkWell: false, + // 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기") + : Semantics( + button: true, + label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]), + excludeSemantics: true, onTap: () => _toggle(pane, e), - child: Center( - child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)), + child: InkResponse( + radius: chevronWidth / 2 + 4, + containedInkWell: false, + onTap: () => _toggle(pane, e), + child: Center( + child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)), + ), )) : null, ); diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 214a4ee..7526ea6 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -341,19 +341,21 @@ class _TopBar extends StatelessWidget { {required bool text, required String tip, Color? color, bool ai = false}) { final fg = ai ? aiColor : null; final side = BorderSide(color: ai && onPressed != null ? aiColor.withValues(alpha: 0.7) : JjColors.border); + // 241: 아이콘만 있는 버튼은 이름을 버튼 자체에 (바깥 Tooltip 은 TalkBack 이 읽는 이름이 되지 않았다) + if (!text) { + return IconButton.outlined( + tooltip: tip, + onPressed: onPressed, + icon: Icon(icon, size: 20, color: onPressed == null ? null : color ?? fg), + style: IconButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: side)); + } return Tooltip( message: tip, - child: text - ? OutlinedButton.icon( - style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null, - onPressed: onPressed, - icon: Icon(icon, size: 18, color: color ?? fg), - label: Text(label)) - : IconButton.outlined( - onPressed: onPressed, - icon: Icon(icon, size: 20, color: onPressed == null ? null : color ?? fg), - style: IconButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: side)), + child: OutlinedButton.icon( + style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null, + onPressed: onPressed, + icon: Icon(icon, size: 18, color: color ?? fg), + label: Text(label)), ); } @@ -477,7 +479,8 @@ class _TopBar extends StatelessWidget { ], // 체크한 동영상이 있으면 그것만, 없으면 "전체 만들기 / 취소" 를 묻는다 Tooltip( - message: trf('MKV 만들기{0}', [count]), + // 241: 아이콘만일 때는 버튼 자체의 tooltip (이름) 을 쓰고 바깥은 비운다 (두 번 뜨지 않게) + message: mkvText ? trf('MKV 만들기{0}', [count]) : '', child: mkvText ? FilledButton.icon( onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), @@ -485,6 +488,7 @@ class _TopBar extends StatelessWidget { label: Text(trf('MKV 만들기{0}{1}', [count, c.busy && jobText ? tr(' (대기열)') : ''])), ) : IconButton.filled( + tooltip: trf('MKV 만들기{0}', [count]), // 241: TalkBack 이 읽는 이름 onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), ), @@ -635,7 +639,8 @@ class _EncodeBar extends StatelessWidget { ), ), ]); - if (compact) { + // 241 시험에서 찾음: 폭 720 ~ 840 (노트 10+ 가로 등) 에서 한 줄이 넘쳤다 - 960 보다 좁으면 폰처럼 줄을 바꿔 보인다 (내 판단) + if (compact || MediaQuery.sizeOf(context).width < 960) { return Container( width: double.infinity, color: JjColors.panel, diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index adb81b2..373caff 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -178,7 +178,11 @@ class _DiskInfo extends StatelessWidget { return Row(mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.storage, size: 14, color: JjColors.textDim), const SizedBox(width: 4), - Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]), style: const TextStyle(fontSize: 12)), + // 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다 + Flexible( + child: Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), + ), const SizedBox(width: 8), SizedBox( width: 90, diff --git a/lib/ui/setting_tile.dart b/lib/ui/setting_tile.dart index 7b41301..d6c85f4 100644 --- a/lib/ui/setting_tile.dart +++ b/lib/ui/setting_tile.dart @@ -33,6 +33,13 @@ class SettingTile extends StatelessWidget { @override Widget build(BuildContext context) { + final tile = _tile(context); + // 241: 오른쪽 스위치 · 고르기 상자가 제목을 이름으로 읽히게 (TalkBack 이 이름 없는 "켜짐" 만 읽었다). + // 줄 자체를 누르는 타일 ([onTap]) 은 누르는 곳이 둘이 되므로 합치지 않는다 + return trailing != null && onTap == null ? MergeSemantics(child: tile) : tile; + } + + Widget _tile(BuildContext context) { final t = trailing; if (t == null || _small(t) || !isCompact(context)) { return ListTile( diff --git a/test/a11y_names_test.dart b/test/a11y_names_test.dart new file mode 100644 index 0000000..596358b --- /dev/null +++ b/test/a11y_names_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.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/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:jj_mkvmaker/ui/monitor_page.dart'; +import 'package:jj_mkvmaker/ui/settings_page.dart'; +import 'package:path/path.dart' as p; + +/// 241: TalkBack 이 읽는 이름이 없는 아이콘 버튼이 없다 (아이콘 크기의 누르는 곳 - 넓은 목록 줄은 안의 글을 읽는다). +/// 바깥 Tooltip 으로 감싼 IconButton · 이름 없는 InkResponse 는 Android 에 이름이 가지 않았다 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_a11y_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future> unnamed(WidgetTester tester, Widget Function(AppController) page, double w) async { + final handle = tester.ensureSemantics(); + // 설정 화면의 rsync 확인이 앱 폴더를 묻는다 (시험 환경에는 path_provider 가 없음) + const pp = MethodChannel('plugins.flutter.io/path_provider'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null)); + tester.view.physicalSize = Size(w, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + File(p.join(tmp.path, 'a.txt')).writeAsStringSync('a'); + Directory(p.join(tmp.path, 'sub')).createSync(); + c.settings + ..explorerPaths = [tmp.path, tmp.path] + ..rsyncPaths = [tmp.path, tmp.path] + ..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] + ..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: page(c)))); + for (var i = 0; i < 20; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + } + final out = []; + void walk(SemanticsNode n) { + final d = n.getSemanticsData(); + final tappable = d.flagsCollection.isButton || d.hasAction(SemanticsAction.tap); + if (!n.isMergedIntoParent && + tappable && + n.rect.width <= 72 && + n.rect.height >= 20 && + d.label.trim().isEmpty && + d.tooltip.trim().isEmpty && + d.value.trim().isEmpty) { + out.add('${n.rect} (위: ${n.parent?.getSemanticsData().label})'); + } + n.visitChildren((x) { + walk(x); + return true; + }); + } + + walk(tester.binding.renderViews.first.owner!.semanticsOwner!.rootSemanticsNode!); + handle.dispose(); + return out; + } + + final pages = { + 'MKV': (c) => HomePage(c: c), + '파일 탐색기': (c) => ExplorerPage(c: c), + 'Rsync': (c) => ExplorerPage(c: c, rsync: true), + '모니터링': (c) => MonitorPage(c: c), + '환경 설정': (c) => SettingsPage(c: c), + }; + for (final MapEntry(key: name, value: page) in pages.entries) { + for (final w in [500.0, 800.0, 1600.0]) { + testWidgets('241: $name ($w) - 이름 없는 아이콘 버튼 없음', (tester) async { + expect(await unnamed(tester, page, w), isEmpty); + }); + } + } +}