이름 없는 아이콘 버튼에 TalkBack 이름 · 화면 시험 (241)
- 접근성 (기기 확인): 바깥 Tooltip 으로 감싼 아이콘 버튼은 Android 에 이름이 가지 않았다 - 버튼 자체의 tooltip, 탐색기 펼치기 화살표 이름, 설정 줄 MergeSemantics - a11y_names_test (다섯 화면 · 세 폭) 로 찾은 넘침도 고침: 모니터링 디스크 줄, MKV 인코딩 줄 (960 보다 좁으면 줄 바꿈 - 내 판단, 관리자 승인) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음5
parent
2239e70013
commit
70e6071991
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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} に移動します",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -74,14 +74,23 @@ class _AppTopBarState extends State<AppTopBar> {
|
|||
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<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 {
|
||||
final AppController controller;
|
||||
|
|
|
|||
|
|
@ -3367,12 +3367,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
? (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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue