이름 없는 아이콘 버튼에 TalkBack 이름 · 화면 시험 (241)

- 접근성 (기기 확인): 바깥 Tooltip 으로 감싼 아이콘 버튼은 Android 에 이름이 가지 않았다 - 버튼 자체의 tooltip, 탐색기 펼치기 화살표 이름, 설정 줄 MergeSemantics
- a11y_names_test (다섯 화면 · 세 폭) 로 찾은 넘침도 고침: 모니터링 디스크 줄, MKV 인코딩 줄 (960 보다 좁으면 줄 바꿈 - 내 판단, 관리자 승인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음5
RyuJongJin 2026-10-12 01:28:11 +09:00
parent 2239e70013
commit 70e6071991
9 changed files with 171 additions and 27 deletions

View File

@ -352,10 +352,12 @@
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} is empty (insert a card, USB drive or disc and tap again).", "{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} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
"{0} 전체": "All of {0}", "{0} 전체": "All of {0}",
"{0} 접기": "Collapse {0}",
"{0} 제거": "Remove {0}", "{0} 제거": "Remove {0}",
"{0} 추가": "Add {0}", "{0} 추가": "Add {0}",
"{0} 파일": "{0} file", "{0} 파일": "{0} file",
"{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)", "{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} 폴더가 없거나 열 수 없어 내역에서 뺐습니다": "Folder {0} is missing or can't be opened, so it was removed from history",
"{0}. {1} (대기)": "{0}. {1} (waiting)", "{0}. {1} (대기)": "{0}. {1} (waiting)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}",

View File

@ -352,10 +352,12 @@
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} は空です (カード・USB・ディスクを入れてからもう一度押してください)。",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
"{0} 전체": "{0} 全体", "{0} 전체": "{0} 全体",
"{0} 접기": "{0} を折りたたむ",
"{0} 제거": "{0} の削除", "{0} 제거": "{0} の削除",
"{0} 추가": "{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}(待機)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します",

View File

@ -352,10 +352,12 @@
"{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。", "{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).": "{0} 为空 (插入存储卡、U 盘或光盘后再点按)。",
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。", "{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
"{0} 전체": "{0} 全部", "{0} 전체": "{0} 全部",
"{0} 접기": "折叠 {0}",
"{0} 제거": "移除 {0}", "{0} 제거": "移除 {0}",
"{0} 추가": "添加 {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}(等待)",
"{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}",

View File

@ -74,14 +74,23 @@ class _AppTopBarState extends State<AppTopBar> {
height: appBarHeight, height: appBarHeight,
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
// 242: middle 이 넘쳐도 오른쪽 (화면 크기 "100%" · 설정 · 종료) 을 덮어 그리지 않게 // 242: middle 이 넘쳐도 오른쪽 (화면 크기 "100%" · 설정 · 종료) 을 덮어 그리지 않게.
child: Row(children: [ // trailing 이 있으면 제 폭대로 재고 남는 폭을 middle 에 (모자라면 [_check] 가 두 줄로 - 그사이 넘침 오류 없이 잘라 그림)
nav, child: widget.trailing == null
const SizedBox(width: 8), ? Row(children: [
Expanded(child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), nav,
if (widget.trailing != null) widget.trailing!, const SizedBox(width: 8),
actions, 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( return Container(
@ -106,6 +115,27 @@ class _AppTopBarState extends State<AppTopBar> {
} }
} }
/// 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 { class AppScope extends InheritedWidget {
final AppController controller; final AppController controller;

View File

@ -3367,12 +3367,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
? (loading ? (loading
? const Center( ? const Center(
child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5))) child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)))
: InkResponse( // 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기")
radius: chevronWidth / 2 + 4, : Semantics(
containedInkWell: false, button: true,
label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]),
excludeSemantics: true,
onTap: () => _toggle(pane, e), onTap: () => _toggle(pane, e),
child: Center( child: InkResponse(
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)), 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, : null,
); );

View File

@ -341,19 +341,21 @@ class _TopBar extends StatelessWidget {
{required bool text, required String tip, Color? color, bool ai = false}) { {required bool text, required String tip, Color? color, bool ai = false}) {
final fg = ai ? aiColor : null; final fg = ai ? aiColor : null;
final side = BorderSide(color: ai && onPressed != null ? aiColor.withValues(alpha: 0.7) : JjColors.border); 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( return Tooltip(
message: tip, message: tip,
child: text child: OutlinedButton.icon(
? OutlinedButton.icon( style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null,
style: ai ? OutlinedButton.styleFrom(foregroundColor: fg, side: side) : null, onPressed: onPressed,
onPressed: onPressed, icon: Icon(icon, size: 18, color: color ?? fg),
icon: Icon(icon, size: 18, color: color ?? fg), label: Text(label)),
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)),
); );
} }
@ -477,7 +479,8 @@ class _TopBar extends StatelessWidget {
], ],
// 체크한 동영상이 있으면 그것만, 없으면 "전체 만들기 / 취소" 를 묻는다 // 체크한 동영상이 있으면 그것만, 없으면 "전체 만들기 / 취소" 를 묻는다
Tooltip( Tooltip(
message: trf('MKV 만들기{0}', [count]), // 241: 아이콘만일 때는 버튼 자체의 tooltip (이름) 을 쓰고 바깥은 비운다 (두 번 뜨지 않게)
message: mkvText ? trf('MKV 만들기{0}', [count]) : '',
child: mkvText child: mkvText
? FilledButton.icon( ? FilledButton.icon(
onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), 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(' (대기열)') : ''])), label: Text(trf('MKV 만들기{0}{1}', [count, c.busy && jobText ? tr(' (대기열)') : ''])),
) )
: IconButton.filled( : IconButton.filled(
tooltip: trf('MKV 만들기{0}', [count]), // 241: TalkBack 이 읽는 이름
onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)), onPressed: !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)),
icon: Icon(c.busy ? Icons.playlist_add : Icons.play_arrow, size: 20), 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( return Container(
width: double.infinity, width: double.infinity,
color: JjColors.panel, color: JjColors.panel,

View File

@ -178,7 +178,11 @@ class _DiskInfo extends StatelessWidget {
return Row(mainAxisSize: MainAxisSize.min, children: [ return Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.storage, size: 14, color: JjColors.textDim), const Icon(Icons.storage, size: 14, color: JjColors.textDim),
const SizedBox(width: 4), 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), const SizedBox(width: 8),
SizedBox( SizedBox(
width: 90, width: 90,

View File

@ -33,6 +33,13 @@ class SettingTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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; final t = trailing;
if (t == null || _small(t) || !isCompact(context)) { if (t == null || _small(t) || !isCompact(context)) {
return ListTile( return ListTile(

85
test/a11y_names_test.dart Normal file
View File

@ -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<List<String>> 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<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
}
final out = <String>[];
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 = <String, Widget Function(AppController)>{
'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);
});
}
}
}