From d1081f337ebd21cfbdb0cc817af45fa0029e96b2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:06:21 +0900 Subject: [PATCH] =?UTF-8?q?W=20JjChip:=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B9=A9=2021=EA=B3=B3=EC=9D=84=20JjChip=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A1=9C=20+=20=EC=97=AC=EB=9F=BF=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=EC=9D=80=20withPicked=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20+=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=20=EC=BA=A1=EC=B2=98=20=ED=9D=94=EB=93=A4=EB=A6=BC=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/ui/jj_chip.dart: 아이콘 없는 칩은 Material 처음 꼴 (고르면 앞에 ✓), 아이콘 칩은 고르면 그 자리에 ✓ 만 (62ba78d 규칙 - 예전에는 화면 · 색 보정 비율 칩 한 곳만 손으로). multi 면 FilterChip. 화면 · 색 보정 · 압축 · 찾기 · 일정 · 저장 공간 · 보기 · 정리 · 받기 설정 · 즐겨찾기 · 자막 찾기 · 복사 방식 · 원본 정리 등 21곳이 부르기만 한다. 탐색기 왼쪽 / 오른쪽 창 고르기 칩은 고르기가 아니라 탭이라 그대로 - lib/core/picked_list.dart withPicked: 여럿 고르기 목록 켜고 끄기 (설정 3곳에 같은 코드가 있던 것) - 시험: jj_chip_contract_test (아이콘 ✓ 바꾸기 · 처음 꼴 · multi · 못 누름 · withPicked) - 앞 · 뒤 캡처 9 묶음 132장 각 두 번: 앞 · 뒤 0px (보이는 것은 그대로) - 시험 쪽: monitor_shot 도 고정 폴더 · shotServices (CPU · 메모리 · 디스크 · 대상 디스크) Co-Authored-By: Claude Opus 5.5 --- integration_test/monitor_shot_test.dart | 8 +++-- lib/core/picked_list.dart | 9 +++++ lib/ui/archive_ui.dart | 5 +-- lib/ui/browser/bookmark_panel.dart | 6 ++-- lib/ui/component_settings.dart | 12 +++---- lib/ui/copy_sync_settings.dart | 3 +- lib/ui/explorer/view_dialogs.dart | 5 +-- lib/ui/explorer_search.dart | 5 +-- lib/ui/jj_chip.dart | 37 +++++++++++++++++++++ lib/ui/monitor/copy_task_card.dart | 3 +- lib/ui/monitor/transfer_widgets.dart | 3 +- lib/ui/schedule_editor.dart | 9 ++--- lib/ui/settings/app_sections.dart | 13 ++++---- lib/ui/settings/transfer_sections.dart | 13 ++++---- lib/ui/storage_page.dart | 7 ++-- lib/ui/subtitle_search_dialog.dart | 7 ++-- lib/ui/video_adjust_dialog.dart | 18 +++++----- test/jj_chip_contract_test.dart | 44 +++++++++++++++++++++++++ 18 files changed, 157 insertions(+), 50 deletions(-) create mode 100644 lib/core/picked_list.dart create mode 100644 lib/ui/jj_chip.dart create mode 100644 test/jj_chip_contract_test.dart diff --git a/integration_test/monitor_shot_test.dart b/integration_test/monitor_shot_test.dart index 4c1689a..2126a37 100644 --- a/integration_test/monitor_shot_test.dart +++ b/integration_test/monitor_shot_test.dart @@ -9,10 +9,10 @@ import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/copy_center.dart'; import 'package:jj_mkvmaker/app/live_sync.dart'; import 'package:jj_mkvmaker/app/settings.dart'; -import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:jj_mkvmaker/ui/schedule_editor.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/shot_services.dart'; import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; @@ -24,7 +24,9 @@ void main() { testWidgets('모니터링 화면 그림', (tester) async { final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; - final tmp = testTempDir('jj_monshot_'); + // 관리자 지시 (10/24): 폴더 이름 · CPU · 메모리 · 디스크를 정한 값으로 (캡처가 흔들리지 않게 - 시험 쪽에서만). + // 진행 줄 (속도 · % · 몇 번째 파일) 은 실제로 복사하는 중의 값이라 찍는 때에 따라 다를 수 있다 + final tmp = testFixedTempDir('jj_monshot_fixed'); final src = p.join(tmp.path, '영상 원본'), dst = p.join(tmp.path, '백업'); for (var i = 0; i < 6; i++) { File(p.join(src, i < 3 ? 'A' : 'B', 'clip$i.mkv')) @@ -32,7 +34,7 @@ void main() { ..writeAsBytesSync(List.filled(200 * 1024, i)); } Directory(dst).createSync(); - final c = AppController(PlatformServices.create()); + final c = AppController(shotServices()); c.settings.copy.liveSyncPairs = [ LiveSyncPair(src, p.join(dst, 'mirror'), schedule: const ['0 9-17 * * 1-5', '0 22-23 * * 0,6'], delete: true), ]; diff --git a/lib/core/picked_list.dart b/lib/core/picked_list.dart new file mode 100644 index 0000000..bfedb24 --- /dev/null +++ b/lib/core/picked_list.dart @@ -0,0 +1,9 @@ +/// 약속 (여럿 고르기 목록): [withPicked] 목록 · 항목 · 켬 → 새 목록 (켜면 끝에 하나, 끄면 빼기 - 같은 것이 둘 생기지 않음, 다른 차례는 그대로). +/// 원래 목록은 바꾸지 않는다. 오류 없음. 쓰는 곳: 설정의 여럿 고르기 칩 (그림 확장자 · 정리 미리 체크 · 로그인 쿠키 사이트) +library; + +List withPicked(List list, T item, bool on) => [ + for (final x in list) + if (x != item) x, + if (on) item, + ]; diff --git a/lib/ui/archive_ui.dart b/lib/ui/archive_ui.dart index dcf3a49..70c2938 100644 --- a/lib/ui/archive_ui.dart +++ b/lib/ui/archive_ui.dart @@ -14,6 +14,7 @@ import '../l10n/tr.dart'; import 'app_actions.dart' show isCompact; import 'theme.dart'; import 'secret_field.dart'; +import 'jj_chip.dart'; /// 86: 동봉한 7-Zip. 없으면 null /// - Windows: 실행 파일 옆 7zip\7z.exe (개발 중에는 third_party 의 것) @@ -290,7 +291,7 @@ Future extractArchiveFlow(BuildContext context, AppController c, String Widget _placeChoice(BuildContext context, List<(String, String)> choices, String value, void Function(String) onPick) { if (!isCompact(context)) { return Wrap(spacing: 6, runSpacing: 4, children: [ - for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: value == d, onSelected: (_) => onPick(d)), + for (final (d, label) in choices) JjChip(label: label, selected: value == d, onSelected: (_) => onPick(d)), ]); } return RadioGroup( @@ -348,7 +349,7 @@ Future createZipFlow(BuildContext context, AppController c, List set(() => level = v)), + JjChip(label: label, selected: level == v, onSelected: (_) => set(() => level = v)), ]), const SizedBox(height: 6), _placeChoice(ctx, choices, where, (d) => set(() => where = d)), diff --git a/lib/ui/browser/bookmark_panel.dart b/lib/ui/browser/bookmark_panel.dart index c6a91cf..f68a7d4 100644 --- a/lib/ui/browser/bookmark_panel.dart +++ b/lib/ui/browser/bookmark_panel.dart @@ -9,6 +9,7 @@ import '../bookmark_ui.dart'; import '../key_hint.dart'; import '../theme.dart'; import '../jj_header_row.dart'; +import '../jj_chip.dart'; // ───────── 즐겨찾기 관리 패널 (Chrome 즐겨찾기 관리자 참고) ───────── @@ -87,8 +88,9 @@ class _BookmarkPanelState extends State { padding: const EdgeInsets.symmetric(horizontal: 8), child: Wrap(spacing: 4, children: [ for (final r in bm.tree.roots) - ChoiceChip( - label: Text(r.title, style: const TextStyle(fontSize: 11)), + JjChip( + label: r.title, + style: const TextStyle(fontSize: 11), selected: _path().first.id == r.id, onSelected: (_) => setState(() => _folderId = r.id), ), diff --git a/lib/ui/component_settings.dart b/lib/ui/component_settings.dart index 699aa19..791f4c5 100644 --- a/lib/ui/component_settings.dart +++ b/lib/ui/component_settings.dart @@ -10,6 +10,8 @@ import '../app/tor_service.dart'; import '../l10n/tr.dart'; import 'setting_tile.dart'; import 'theme.dart'; +import 'jj_chip.dart'; +import '../core/picked_list.dart'; /// 환경 설정 > 파일 탐색기 > 보기: 그림으로 볼 확장자 · ZIP 만화 보기 (이미지 · PDF · ZIP 보기 컴포넌트) class ViewerSettings extends StatelessWidget { @@ -31,13 +33,11 @@ class ViewerSettings extends StatelessWidget { const SizedBox(height: 6), Wrap(spacing: 6, runSpacing: 6, children: [ for (final e in _choices) - FilterChip( - label: Text(e), + JjChip( + multi: true, + label: e, selected: s.reader.imageExts.contains(e), - onSelected: (on) => c.updateSettings((x) => x.reader.imageExts = [ - for (final y in x.reader.imageExts) if (y != e) y, - if (on) e, - ]), + onSelected: (on) => c.updateSettings((x) => x.reader.imageExts = withPicked(x.reader.imageExts, e, on)), ), ]), ]), diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 89954e4..1b4e7ca 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -20,6 +20,7 @@ import 'schedule_editor.dart'; import 'theme.dart'; import 'setting_tile.dart'; import 'sync_preview_view.dart' show OptionTextFormatter, keepOptionsTogether, optionProblemText; +import 'jj_chip.dart'; /// 환경 설정의 복사 · 동기화 부분. /// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한 @@ -335,7 +336,7 @@ class _LiveSyncTile extends StatelessWidget { Wrap(spacing: 6, children: [ for (final m in CopyMethod.values) if (copyMethodAvailable(m, c.settings)) - ChoiceChip(label: Text(tr(m.label)), selected: method == m.name, onSelected: (_) => set(() => method = m.name)), + JjChip(label: tr(m.label), selected: method == m.name, onSelected: (_) => set(() => method = m.name)), ]), CheckboxListTile( contentPadding: EdgeInsets.zero, diff --git a/lib/ui/explorer/view_dialogs.dart b/lib/ui/explorer/view_dialogs.dart index bf722b7..ee37bf5 100644 --- a/lib/ui/explorer/view_dialogs.dart +++ b/lib/ui/explorer/view_dialogs.dart @@ -7,6 +7,7 @@ import '../theme.dart'; import 'explorer_buttons.dart'; import 'explorer_controller.dart'; import 'explorer_pane.dart'; +import '../jj_chip.dart'; /// 약속 (E5d-6): 보기를 바꾸는 창 - 정렬 기준 · 즐겨찾기 (Rsync 는 짝) · 내역 · 창 배치 (좌우 ⇆ 위아래 한 번에 · 배치 창) · 버튼 구성. /// 고른 것은 설정에 저장하고 (취소면 그대로), 배치가 바뀌면 보던 폴더가 다시 보이게 한다. [twoPanes]: 지금 창이 둘인지 @@ -35,7 +36,7 @@ class ExplorerViewDialogs { ('size', tr('크기')), ('type', tr('종류 (확장자)')), ]) - ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)), + JjChip(label: label, selected: by == v, onSelected: (_) => set(() => by = v)), ]), SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)), if (ctl.c.settings.explorer.sortPerFolder) @@ -131,7 +132,7 @@ class ExplorerViewDialogs { ), Wrap(spacing: 6, children: [ for (final (v, label) in items) - ChoiceChip(label: Text(label), selected: value == v, onSelected: (_) => set(() => on(v))), + JjChip(label: label, selected: value == v, onSelected: (_) => set(() => on(v))), ]), ]); return AlertDialog( diff --git a/lib/ui/explorer_search.dart b/lib/ui/explorer_search.dart index 2342fac..ea837b0 100644 --- a/lib/ui/explorer_search.dart +++ b/lib/ui/explorer_search.dart @@ -11,6 +11,7 @@ import '../l10n/tr.dart'; import 'app_actions.dart' show isCompact; import 'theme.dart'; import 'jj_title_bar.dart'; +import 'jj_chip.dart'; /// 79: 찾기 조건 - 이름 (없어도 됨) · 크기 · 바뀐 날 · 종류 ("1GB 넘는 30일 지난 영상") class SearchConditions { @@ -209,8 +210,8 @@ class _ConditionsDialogState extends State<_ConditionsDialog> { // 종류 Wrap(spacing: 6, runSpacing: 4, children: [ for (final k in const ['all', 'video', 'audio', 'subtitle', 'image', 'custom']) - ChoiceChip( - label: Text(SearchConditions.kindLabel(k)), + JjChip( + label: SearchConditions.kindLabel(k), selected: _kind == k, onSelected: (_) => setState(() => _kind = k), ), diff --git a/lib/ui/jj_chip.dart b/lib/ui/jj_chip.dart new file mode 100644 index 0000000..50937cb --- /dev/null +++ b/lib/ui/jj_chip.dart @@ -0,0 +1,37 @@ +/// 약속 (W 공용 위젯, 설계 7-2 JjChip): 고르기 칩 하나. +/// 입력: 이름 · 고름 · 누름 알림 (null 이면 누를 수 없음) · 아이콘 (있으면) · 여럿 고르기 ([multi]) · 이름 글꼴. +/// 출력: 아이콘이 없으면 Material 처음 꼴 (고르면 앞에 ✓). 아이콘이 있으면 고를 때 그 자리에 ✓ 만 (그림 위에 체크가 겹치지 않게 - 62ba78d). +/// [multi] 면 FilterChip (여럿 켜고 끄기 - 목록은 core/picked_list 의 withPicked), 아니면 ChoiceChip (하나 고르기). 오류 없음. +/// 쓰는 곳: 화면 · 색 보정 · 압축 · 찾기 · 일정 · 저장 공간 · 보기 · 정리 · 받기 설정 · 즐겨찾기 등. 화면은 부르기만 한다 +library; + +import 'package:flutter/material.dart'; + +class JjChip extends StatelessWidget { + const JjChip({ + super.key, + required this.label, + required this.selected, + required this.onSelected, + this.icon, + this.multi = false, + this.style, + }); + + final String label; + final bool selected; + final ValueChanged? onSelected; + final IconData? icon; + final bool multi; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final text = Text(label, style: style); + final avatar = icon == null ? null : Icon(selected ? Icons.check : icon, size: 16); + final check = icon == null; + return multi + ? FilterChip(label: text, avatar: avatar, showCheckmark: check, selected: selected, onSelected: onSelected) + : ChoiceChip(label: text, avatar: avatar, showCheckmark: check, selected: selected, onSelected: onSelected); + } +} diff --git a/lib/ui/monitor/copy_task_card.dart b/lib/ui/monitor/copy_task_card.dart index d869ec1..3cefc25 100644 --- a/lib/ui/monitor/copy_task_card.dart +++ b/lib/ui/monitor/copy_task_card.dart @@ -27,6 +27,7 @@ import '../../core/sync_preview.dart' show optionsRemoveSource; import '../sync_preview_view.dart' show OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText; import '../theme.dart'; import 'transfer_widgets.dart'; +import '../jj_chip.dart'; class CopyTaskList extends StatelessWidget { final AppController c; @@ -384,7 +385,7 @@ class _CopyTaskCardState extends State { ), ), if (t.sources.length > 1 && _method == 'rsync') - FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)), + JjChip(multi: true, label: tr('한 번에'), selected: _once, onSelected: (v) => setState(() => _once = v)), // 이동 (원본 파일 지우기) 이면: 끝난 뒤 원본의 빈 폴더 · 원본 폴더 정리 if (t.move && _method == 'rsync') DropdownButton( diff --git a/lib/ui/monitor/transfer_widgets.dart b/lib/ui/monitor/transfer_widgets.dart index 4196454..3f2dc62 100644 --- a/lib/ui/monitor/transfer_widgets.dart +++ b/lib/ui/monitor/transfer_widgets.dart @@ -17,6 +17,7 @@ import '../../app/transfer_job.dart'; import '../../core/file_ops.dart'; import '../../l10n/tr.dart'; import '../theme.dart'; +import '../jj_chip.dart'; /// 실행 시각 (ISO) 을 "월/일 시:분" 으로. 날짜가 아니면 빈 글. String formatRunStamp(String iso) { @@ -143,6 +144,6 @@ class PruneChoice extends StatelessWidget { @override Widget build(BuildContext context) => Wrap(spacing: 6, runSpacing: 6, children: [ for (final v in [if (allowNone) '', 'keep', 'all']) - ChoiceChip(label: Text(pruneLabel(v)), selected: value == v, onSelected: (_) => onChanged(v)), + JjChip(label: pruneLabel(v), selected: value == v, onSelected: (_) => onChanged(v)), ]); } diff --git a/lib/ui/schedule_editor.dart b/lib/ui/schedule_editor.dart index 11e1778..4db9386 100644 --- a/lib/ui/schedule_editor.dart +++ b/lib/ui/schedule_editor.dart @@ -4,6 +4,7 @@ import '../core/cron_window.dart'; import '../l10n/tr.dart'; import 'theme.dart'; import 'jj_field.dart'; +import 'jj_chip.dart'; /// 요일 이름 (0 = 일요일) List weekdayNames() => [tr('일'), tr('월'), tr('화'), tr('수'), tr('목'), tr('금'), tr('토')]; @@ -85,13 +86,13 @@ class _ScheduleDialogState extends State<_ScheduleDialog> { child: SingleChildScrollView( child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap(spacing: 8, children: [ - ChoiceChip( - label: Text(tr('계속')), + JjChip( + label: tr('계속'), selected: _always, onSelected: (_) => setState(() => _always = true), ), - ChoiceChip( - label: Text(tr('시간 지정 (cron)')), + JjChip( + label: tr('시간 지정 (cron)'), selected: !_always, onSelected: (_) => setState(() => _always = false), ), diff --git a/lib/ui/settings/app_sections.dart b/lib/ui/settings/app_sections.dart index 268ed61..fa5159b 100644 --- a/lib/ui/settings/app_sections.dart +++ b/lib/ui/settings/app_sections.dart @@ -12,6 +12,8 @@ import '../theme.dart'; import '../../l10n/tr.dart'; import '../setting_tile.dart'; import '../license_texts.dart'; +import '../jj_chip.dart'; +import '../../core/picked_list.dart'; // 약속 (설정 B): 환경 설정의 분야 - 실행 · 종료 · 저장 공간 정리 · 프로그램 정보. 바꾸면 바로 설정에 저장 (c.updateSettings) @@ -151,13 +153,12 @@ class _CleanupSettingsSectionState extends State { ('update', tr('업데이트 · 설치하고 남은 파일')), ('logs', tr('지난 작업 기록')), ]) - FilterChip( - label: Text(label), + JjChip( + multi: true, + label: label, selected: s.general.cleanupPrechecked.contains(id), - onSelected: (on) => c.updateSettings((x) => x.general.cleanupPrechecked = [ - for (final y in x.general.cleanupPrechecked) if (y != id) y, - if (on) id, - ]), + onSelected: (on) => + c.updateSettings((x) => x.general.cleanupPrechecked = withPicked(x.general.cleanupPrechecked, id, on)), ), ]), ), diff --git a/lib/ui/settings/transfer_sections.dart b/lib/ui/settings/transfer_sections.dart index c42e80e..46710ef 100644 --- a/lib/ui/settings/transfer_sections.dart +++ b/lib/ui/settings/transfer_sections.dart @@ -9,6 +9,8 @@ import '../setting_tile.dart'; import '../browser_page.dart' show deleteExportedCookies; import '../toast_status.dart'; import 'settings_tiles.dart'; +import '../jj_chip.dart'; +import '../../core/picked_list.dart'; // 약속 (설정 B): 환경 설정의 분야 - Rsync · 다운로드. 바꾸면 바로 설정에 저장 (c.updateSettings) @@ -193,13 +195,12 @@ class _DownloadSettingsSectionState extends State { const SizedBox(height: 6), Wrap(spacing: 6, runSpacing: 6, children: [ for (final d in loginCookieDomains) - FilterChip( - label: Text(d), + JjChip( + multi: true, + label: d, selected: s.download.loginCookieSites.contains(d), - onSelected: (on) => c.updateSettings((x) => x.download.loginCookieSites = [ - for (final y in x.download.loginCookieSites) if (y != d) y, - if (on) d, - ]), + onSelected: (on) => + c.updateSettings((x) => x.download.loginCookieSites = withPicked(x.download.loginCookieSites, d, on)), ), OutlinedButton.icon( icon: const Icon(Icons.cookie_outlined, size: 18), diff --git a/lib/ui/storage_page.dart b/lib/ui/storage_page.dart index 2ce7fc3..aa58db5 100644 --- a/lib/ui/storage_page.dart +++ b/lib/ui/storage_page.dart @@ -7,6 +7,7 @@ import '../l10n/tr.dart'; import 'storage_chart.dart'; import 'theme.dart'; import 'jj_title_bar.dart'; +import 'jj_chip.dart'; /// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례. /// 폴더를 누르면 그 안으로 (한 번 훑은 결과로 바로), [그 자리로] 는 탐색기에서 그 자리로 (돌려주는 값). @@ -202,9 +203,9 @@ class _StorageAnalysisPageState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Wrap(spacing: 6, children: [ - ChoiceChip(label: Text(tr('큰 폴더')), selected: _tab == 0, onSelected: (_) => setState(() => _tab = 0)), - ChoiceChip(label: Text(tr('큰 파일')), selected: _tab == 1, onSelected: (_) => setState(() => _tab = 1)), - ChoiceChip(label: Text(tr('칸 그림')), selected: _tab == 2, onSelected: (_) => setState(() => _tab = 2)), + JjChip(label: tr('큰 폴더'), selected: _tab == 0, onSelected: (_) => setState(() => _tab = 0)), + JjChip(label: tr('큰 파일'), selected: _tab == 1, onSelected: (_) => setState(() => _tab = 1)), + JjChip(label: tr('칸 그림'), selected: _tab == 2, onSelected: (_) => setState(() => _tab = 2)), ]), ), const Divider(height: 1), diff --git a/lib/ui/subtitle_search_dialog.dart b/lib/ui/subtitle_search_dialog.dart index 7bb5683..79331d9 100644 --- a/lib/ui/subtitle_search_dialog.dart +++ b/lib/ui/subtitle_search_dialog.dart @@ -7,6 +7,7 @@ import '../core/models.dart'; import '../core/subtitle_search.dart'; import 'theme.dart'; import '../l10n/tr.dart'; +import 'jj_chip.dart'; /// 인터넷 자막 찾기 → 목록에서 골라 받기 Future showSubtitleSearch(BuildContext context, AppController c, VideoItem v) => @@ -223,10 +224,12 @@ class _SearchDialogState extends State<_SearchDialog> { ), const SizedBox(width: 12), if (_hash != null) - FilterChip( + JjChip( + multi: true, selected: _useHash, onSelected: (v) => setState(() => _useHash = v), - label: Text(tr('이 파일에 맞는 자막 우선 (영상 해시)'), style: TextStyle(fontSize: 12)), + label: tr('이 파일에 맞는 자막 우선 (영상 해시)'), + style: const TextStyle(fontSize: 12), ), ]), ]); diff --git a/lib/ui/video_adjust_dialog.dart b/lib/ui/video_adjust_dialog.dart index 836eda4..bb0d2e3 100644 --- a/lib/ui/video_adjust_dialog.dart +++ b/lib/ui/video_adjust_dialog.dart @@ -12,6 +12,7 @@ import '../l10n/tr.dart'; import 'encode_notice.dart'; import 'theme.dart'; import 'jj_header_row.dart'; +import 'jj_chip.dart'; /// 화면 · 색 보정 (파이널 컷의 뷰어 + 인스펙터처럼): 왼쪽은 지금 보고 있는 동영상의 한 장면 미리보기, /// 오른쪽은 화면 비율 · 맞추기 · 회전 · 색 보정. 바꾸면 바로 MKV 만들기 설정에 들어간다. @@ -232,11 +233,10 @@ class _VideoAdjustState extends State<_VideoAdjust> { head(tr('화면 비율')), Wrap(spacing: 6, runSpacing: 6, children: [ for (final f in FrameChoice.values) - ChoiceChip( - // 관리자 지시 (10/21, 28 찾음): 고르면 체크 표가 그림 위에 겹쳐 그려졌다 - 다른 고른 칩처럼 ✓ 만 (그림 자리에) - avatar: Icon(s.frame == f ? Icons.check : frameIcons[f], size: 16), - showCheckmark: false, - label: Text(f.label), + // 관리자 지시 (10/21, 28 찾음): 고르면 체크 표가 그림 위에 겹쳐 그려졌다 - 다른 고른 칩처럼 ✓ 만 (그림 자리에, JjChip) + JjChip( + icon: frameIcons[f], + label: f.label, selected: s.frame == f, onSelected: (_) => _set(s.copyWith(frame: f)), ), @@ -244,8 +244,8 @@ class _VideoAdjustState extends State<_VideoAdjust> { head(tr('맞추기 (비율이 다를 때)')), Wrap(spacing: 6, runSpacing: 6, children: [ for (final f in FitChoice.values) - ChoiceChip( - label: Text(f.label), + JjChip( + label: f.label, selected: s.fit == f, onSelected: s.frame == FrameChoice.original ? null : (_) => _set(s.copyWith(fit: f)), ), @@ -253,8 +253,8 @@ class _VideoAdjustState extends State<_VideoAdjust> { head(tr('회전')), Wrap(spacing: 6, runSpacing: 6, children: [ for (final r in RotateChoice.values) - ChoiceChip( - label: Text(r.label), + JjChip( + label: r.label, selected: s.rotate == r, onSelected: (_) => _set(s.copyWith(rotate: r)), ), diff --git a/test/jj_chip_contract_test.dart b/test/jj_chip_contract_test.dart new file mode 100644 index 0000000..99e0d9a --- /dev/null +++ b/test/jj_chip_contract_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/picked_list.dart'; +import 'package:jj_mkvmaker/ui/jj_chip.dart'; + +/// W 공용 위젯 약속 (JjChip · withPicked): 아이콘 칩은 고르면 그 자리에 ✓ 만 (그림 위에 체크가 겹치지 않음 - 62ba78d) · +/// 아이콘 없는 칩은 Material 처음 꼴 · multi 는 켜고 끄기. 위젯 하나에 대해 한 번만 둔다 (설계 7-2) +void main() { + Future show(WidgetTester t, Widget chip) => + t.pumpWidget(MaterialApp(home: Scaffold(body: Center(child: chip)))); + + testWidgets('아이콘 칩: 고르지 않으면 그림, 고르면 그 자리에 ✓ 만 (Material 체크 표는 끔)', (t) async { + await show(t, JjChip(label: '세로 9:16', icon: Icons.crop_portrait, selected: false, onSelected: (_) {})); + expect(find.byIcon(Icons.crop_portrait), findsOneWidget); + expect(t.widget(find.byType(ChoiceChip)).showCheckmark, isFalse); + await show(t, JjChip(label: '세로 9:16', icon: Icons.crop_portrait, selected: true, onSelected: (_) {})); + expect(find.byIcon(Icons.crop_portrait), findsNothing); + expect(find.byIcon(Icons.check), findsOneWidget); + }); + + testWidgets('아이콘 없는 칩: Material 처음 꼴 (체크 표 켬) · 하나 고르기는 ChoiceChip, 여럿은 FilterChip', (t) async { + await show(t, JjChip(label: '큰 폴더', selected: true, onSelected: (_) {})); + expect(t.widget(find.byType(ChoiceChip)).showCheckmark, isTrue); + bool? got; + await show(t, JjChip(multi: true, label: 'mp4', selected: false, onSelected: (v) => got = v)); + expect(find.byType(FilterChip), findsOneWidget); + await t.tap(find.text('mp4')); + expect(got, isTrue); + }); + + testWidgets('누름 알림이 없으면 누를 수 없음', (t) async { + await show(t, const JjChip(label: '채우기', selected: false, onSelected: null)); + expect(t.widget(find.byType(ChoiceChip)).onSelected, isNull); + }); + + test('withPicked: 켜면 끝에 하나 (이미 있으면 끝으로), 끄면 빼기, 원래 목록은 그대로', () { + final list = ['jpg', 'png']; + expect(withPicked(list, 'gif', true), ['jpg', 'png', 'gif']); + expect(withPicked(list, 'jpg', true), ['png', 'jpg']); + expect(withPicked(list, 'jpg', false), ['png']); + expect(withPicked(list, 'bmp', false), ['jpg', 'png']); + expect(list, ['jpg', 'png']); + }); +}