From 77d962bcdf9eb3cf566eb81f1b3e3c3145f3dc76 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:29:35 +0900 Subject: [PATCH] =?UTF-8?q?E5d-4:=20=EA=B8=B0=EB=8A=A5=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=C2=B7=20=EB=B2=84=ED=8A=BC=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=A7=89=EB=8C=80=20=C2=B7=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=ED=8C=A8=EB=84=90=EC=9D=84=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EB=A1=9C=20(explorer=5Fpage=204,229=20=E2=86=92=203,532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - explorer/explorer_buttons.dart: ExplorerButton (옮김) · ExplorerCommands 약속 · ExplorerButtons (누르기 · 길게 = 짝 동작) - explorer/toolbar.dart: ExplorerToolbar (버튼 줄 · 더 보기 · 고정) - explorer/marks_bar.dart: ExplorerMarksBar · markPattern · PatternDialog - explorer/transfer_panel.dart: ExplorerTransferPanel · TransferProgressChip / explorer/job_view.dart: TransferJobView - ExplorerSelect.clearMarks 더함 (표시 지우기) - 화면의 일은 이름을 공개로 바꿔 ExplorerCommands 를 바로 채움 (전달 코드 없음) - 약속 시험: test/explorer_buttons_contract_test.dart - 동작은 그대로 (전체 단위 2회 · 점검 통합 2회 · 픽셀 비교) Co-Authored-By: Claude Opus 5.5 --- lib/ui/explorer/explorer_buttons.dart | 269 +++++++ lib/ui/explorer/explorer_select.dart | 6 + lib/ui/explorer/job_view.dart | 115 +++ lib/ui/explorer/marks_bar.dart | 141 ++++ lib/ui/explorer/toolbar.dart | 185 +++++ lib/ui/explorer/transfer_panel.dart | 142 ++++ lib/ui/explorer_page.dart | 910 +++-------------------- test/explorer_buttons_contract_test.dart | 222 ++++++ 8 files changed, 1186 insertions(+), 804 deletions(-) create mode 100644 lib/ui/explorer/explorer_buttons.dart create mode 100644 lib/ui/explorer/job_view.dart create mode 100644 lib/ui/explorer/marks_bar.dart create mode 100644 lib/ui/explorer/toolbar.dart create mode 100644 lib/ui/explorer/transfer_panel.dart create mode 100644 test/explorer_buttons_contract_test.dart diff --git a/lib/ui/explorer/explorer_buttons.dart b/lib/ui/explorer/explorer_buttons.dart new file mode 100644 index 0000000..c843cbc --- /dev/null +++ b/lib/ui/explorer/explorer_buttons.dart @@ -0,0 +1,269 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../core/file_ops.dart' show FileOps, samePath; +import '../../core/playlist.dart' show isAudioFile, isVideoFile; +import '../../core/vfs.dart' show vDirname, vIsDirSync; +import '../../l10n/tr.dart'; +import '../app_manager_page.dart' show openAppManager; +import '../monitor_page.dart'; +import '../vault_page.dart' show openVault; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'explorer_select.dart'; + +/// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다. +enum ExplorerButton { + up(Icons.arrow_upward, '상위 폴더'), + refresh(Icons.refresh, '새로 고침'), + select(Icons.checklist, '선택'), + // ── Rsync 화면에만 ── + toRight(Icons.arrow_forward, '좌 → 우'), + toLeft(Icons.arrow_back, '좌 ← 우'), + both(Icons.compare_arrows, '좌 ⇄ 우'), + monitor(Icons.monitor_heart_outlined, '모니터링'), + // ── + play(Icons.play_circle_outline, '재생'), + addMkv(Icons.playlist_add, 'MKV 목록에 추가'), + newFolder(Icons.create_new_folder_outlined, '새 폴더'), + rename(Icons.drive_file_rename_outline, '이름 변경'), + copy(Icons.copy_outlined, '복사'), + move(Icons.drive_file_move_outline, '이동'), + paste(Icons.content_paste, '붙여넣기'), + delete(Icons.delete_outline, '삭제'), + search(Icons.search, '찾기'), + sort(Icons.sort, '정렬 기준'), + hidden(Icons.visibility_outlined, '숨은 항목 표시'), + history(Icons.history, '내역'), + // 73: 즐겨찾기 (지금 폴더 넣기 · 한 번에 가기, Rsync 는 짝) + bookmarks(Icons.star_outline, '즐겨찾기'), + // 118: 두 창을 좌우 ⇆ / 위아래 ⇅ 로 한 번에 바꾸기 (한 창이면 두 창 (좌우) 으로) + orient(Icons.swap_horiz, '좌우 ⇆ / 위아래 ⇅'), + layout(Icons.view_quilt_outlined, '창 배치'), + buttons(Icons.tune, '버튼 구성'), + // 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일 + storage(Icons.pie_chart_outline, '저장 공간 분석'), + // 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용) + wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'), + // X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기) + trash(Icons.restore_from_trash_outlined, '휴지통'), + // X-plore 비교 5-2 (사용자 결정 10/21): 앱 관리자 (Android 만) + apps(Icons.apps, '앱 관리자'), + // X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함 (환경 설정 > 보안 에서 켰을 때 열림) + vault(Icons.enhanced_encryption_outlined, '암호 보관함'); + + final IconData icon; + final String label; + const ExplorerButton(this.icon, this.label); + + /// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기) + bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor; + + /// 파일 탐색기에서 고를 수 있는 버튼 + static List get explorerValues => [for (final b in values) if (!b.rsyncOnly && (b != apps || appsButtonSupported())) b]; + + /// 앱 관리자 버튼을 쓰는 기기 (Android - 시험에서 바꿈) + static bool Function() appsButtonSupported = () => Platform.isAndroid; + + /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) + // 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽 + static const rsyncButtons = [toRight, toLeft, both, monitor, up, refresh, newFolder, bookmarks]; + + /// 147 (관리자): 처음 값 (버튼 구성을 바꾸지 않았을 때) 은 자주 쓰는 것부터 - 좁은 화면에서 [더 보기] 로 가는 것은 뒤쪽 + static List get defaultOrder { + const first = [copy, move, delete, newFolder, rename, select, search, bookmarks, orient]; + return [...first, for (final b in explorerValues) if (!first.contains(b)) b]; + } + + static List fromSettings(List names) { + final out = [ + for (final n in names) + for (final b in explorerValues) + if (b.name == n) b, + ]; + // 사용자가 정한 순서는 그대로 (비어 있을 때만 처음 값) + return out.isEmpty ? defaultOrder : out; + } +} + +/// 약속 (E5d-4): 버튼이 부르는 화면의 일 (지우기 · 옮기기 · 이름 바꾸기 · 창 · 재생 등). 흐름 모듈로 옮겨 갈 때마다 이 목록이 줄어든다 +abstract interface class ExplorerCommands { + /// 창 · 메뉴를 띄울 곳 + BuildContext get context; + + /// 다음 지우기는 휴지통을 거치지 않음 (Shift+Delete · [삭제] 길게 누르기 - 창에서 한 번 더 확인) + abstract bool shiftDelete; + + /// 버튼이 다룰 것 (표시한 것, 없으면 지금 줄) + List targetsOf(ExplorerPane pane); + Future runRsync({required bool right, required bool left}); + Future play(List paths, {bool internal = false, bool keepOrder = false}); + Future playFolder(String dir); + Future addMkv(List paths); + + /// 압축 안이면 막고 알림 (막았으면 true) + bool blockArchive(List paths, {bool self = false}); + Future askName(String title, String initial, {String? hint, bool selectAll = false}); + Future renameItem(ExplorerPane pane, String path); + Future batchRename(ExplorerPane pane, List paths); + Future transfer(ExplorerPane pane, List sources, {required bool move}); + Future paste(ExplorerPane pane, String dest); + Future deleteItems(ExplorerPane pane, List paths); + Future openSearch(ExplorerPane pane); + Future openStorage(ExplorerPane pane, String dir); + Future openWifiShare(String dir); + Future openTrash(); + Future sortDialog(); + Future historyDialog(ExplorerPane pane); + Future openBookmarks(ExplorerPane pane); + Future toggleOrientation(); + Future layoutDialog(); + Future buttonsDialog(); +} + +/// 약속 (E5d-4): 기능 버튼 하나 → 할 일 (가운데 줄 · 단축키 · 위 메뉴가 같이 부른다). 지금 창 ([ExplorerController.pane]) 에 한다. +/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host) +class ExplorerButtons { + ExplorerButtons(this.ctl, this.sel, this.cmd); + + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerCommands cmd; + + /// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null) + static String? longPressLabel(ExplorerButton b) => switch (b) { + ExplorerButton.copy => '이동', + ExplorerButton.move => '복사', + ExplorerButton.delete => '영구 삭제', + _ => null, + }; + + /// X-plore b: 길게 누르기 = 짝 동작 (복사 ↔ 이동, 지우기 → 영구 삭제). 짝이 없으면 아무것도 안 함 + Future longPress(ExplorerButton b) async { + switch (b) { + case ExplorerButton.copy: + return press(ExplorerButton.move); + case ExplorerButton.move: + return press(ExplorerButton.copy); + case ExplorerButton.delete: + cmd.shiftDelete = true; + return press(ExplorerButton.delete); + default: + return; + } + } + + Future press(ExplorerButton b) async { + final pane = ctl.pane; + switch (b) { + case ExplorerButton.up: + if (samePath(pane.current, pane.root)) return; + final up = vDirname(pane.current); + pane.expanded.remove(pane.current); + await ctl.goTo(pane, up); + case ExplorerButton.select: + sel.setSelecting(!sel.selecting); + case ExplorerButton.monitor: + await MonitorPage.open(cmd.context, ctl.c); + case ExplorerButton.toRight: + await cmd.runRsync(right: true, left: false); + case ExplorerButton.toLeft: + await cmd.runRsync(right: false, left: true); + case ExplorerButton.both: + await cmd.runRsync(right: true, left: true); + case ExplorerButton.refresh: + // 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게) + await ctl.reloadOpen(); + case ExplorerButton.play: + final t = cmd.targetsOf(pane); + // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) + final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList(); + if (videos.isNotEmpty) { + await cmd.play(videos, keepOrder: !pane.sel.isEmpty); + } else { + await cmd.playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); + } + case ExplorerButton.addMkv: + final t = cmd.targetsOf(pane); + await cmd.addMkv(t.isEmpty ? [pane.current] : t); + case ExplorerButton.newFolder: + // X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다 + if (cmd.blockArchive([pane.current], self: true)) return; + final name = await cmd.askName(tr('새 폴더'), ''); + if (name == null) return; + try { + final parent = pane.current; + final made = await FileOps.makeFolder(parent, name); + // 만든 폴더가 트리에 보이게: 부모를 펼치고 다시 읽은 뒤 그 줄까지 스크롤 + pane.expanded.add(parent); + await ctl.load(pane, parent, force: true); + await ctl.refreshAll([parent]); + // Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다 + if (ctl.rsync) { + pane.sel.clear(); + await pane.sel.toggle(made, isDir: true, list: (_) async => const []); + } + pane.focused = made; + pane.changed(); + ctl.host.reveal(pane, made); + } catch (e) { + ctl.host.snack(trf('폴더를 만들 수 없습니다: {0}', [e])); + } + case ExplorerButton.rename: + final t = cmd.targetsOf(pane); + if (t.isEmpty) { + ctl.host.snack(tr('이름을 바꿀 항목 하나를 고르세요.')); + return; + } + // 78: 여럿을 골랐으면 일괄 이름 바꾸기 + if (t.length > 1) return cmd.batchRename(pane, t); + await cmd.renameItem(pane, t.first); + case ExplorerButton.copy: + await cmd.transfer(pane, cmd.targetsOf(pane), move: false); + case ExplorerButton.move: + await cmd.transfer(pane, cmd.targetsOf(pane), move: true); + case ExplorerButton.paste: + await cmd.paste(pane, pane.current); + case ExplorerButton.delete: + await cmd.deleteItems(pane, cmd.targetsOf(pane)); + case ExplorerButton.search: + await cmd.openSearch(pane); + case ExplorerButton.storage: + await cmd.openStorage(pane, pane.current); + case ExplorerButton.wifiShare: + await cmd.openWifiShare(pane.current); + case ExplorerButton.apps: + await openAppManager(cmd.context); + case ExplorerButton.vault: + if (!ctl.c.settings.vaultEnabled) { + ctl.host.snack(tr('암호 보관함은 환경 설정 > 보안 에서 켭니다 (처음 꺼짐)')); + } else { + await openVault(cmd.context, ctl.c); + } + case ExplorerButton.trash: + await cmd.openTrash(); + case ExplorerButton.sort: + await cmd.sortDialog(); + case ExplorerButton.hidden: + await ctl.c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden); + for (final x in ctl.panes) { + x.cache.clear(); + for (final d in x.expanded.toList()) { + await ctl.load(x, d, force: true); + } + } + ctl.host.snack(ctl.c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.')); + case ExplorerButton.history: + await cmd.historyDialog(pane); + case ExplorerButton.bookmarks: + await cmd.openBookmarks(pane); + case ExplorerButton.orient: + await cmd.toggleOrientation(); + case ExplorerButton.layout: + await cmd.layoutDialog(); + case ExplorerButton.buttons: + await cmd.buttonsDialog(); + } + } +} diff --git a/lib/ui/explorer/explorer_select.dart b/lib/ui/explorer/explorer_select.dart index 81073c5..9879dfd 100644 --- a/lib/ui/explorer/explorer_select.dart +++ b/lib/ui/explorer/explorer_select.dart @@ -170,6 +170,12 @@ class ExplorerSelect extends ChangeNotifier { notifyListeners(); } + /// [표시 지우기]: 이 창의 표시를 모두 풀기 (선택 모드는 그대로) + void clearMarks(ExplorerPane pane) { + pane.sel.clear(); + notifyListeners(); + } + /// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로 Future toggleMark(ExplorerPane pane, FileEntry e) async { pane.focused = e.path; diff --git a/lib/ui/explorer/job_view.dart b/lib/ui/explorer/job_view.dart new file mode 100644 index 0000000..b37dbb2 --- /dev/null +++ b/lib/ui/explorer/job_view.dart @@ -0,0 +1,115 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../app/transfer_job.dart'; +import '../../core/file_ops.dart' show formatSize; +import '../../core/vfs.dart' show vBasename; +import '../../l10n/tr.dart'; +import '../theme.dart'; + +/// 약속 (E5d-4): 복사 · 이동 작업 하나의 진행 - 제목 (복사 · 옮기는 중 · 완료 · 멈춤 + 방법) · 대상 · [취소] (도는 동안). +/// 파일 탐색기: 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중. [rsync] (폴더 하나를 맞춤): 위 = 지금 파일 · 아래 = 폴더 전체. +/// 그리기만 한다 (작업이 바뀌면 부르는 쪽이 다시 그림) +class TransferJobView extends StatelessWidget { + const TransferJobView({super.key, required this.job, required this.rsync}); + + final TransferJob job; + final bool rsync; + + @override + Widget build(BuildContext context) { + String pct(double v) => '${(v * 100).round()}%'; + final cur = job.index < job.total ? job.index : job.total - 1; + final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)]; + final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)]; + final title = job.finished + ? (job.error == null ? tr('완료') : tr('멈춤')) + : job.move + ? tr('옮기는 중') + : tr('복사하는 중'); + final labelMax = MediaQuery.sizeOf(context).width * 0.45; + Widget line(String label, double value, String right) => Padding( + padding: const EdgeInsets.only(top: 6), + child: Row(children: [ + SizedBox( + width: math.min(300, labelMax), // 좁은 화면 (폰 세로) 은 폭의 45% 까지 + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), + ), + const SizedBox(width: 10), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + minHeight: 8, + value: job.counting ? null : value, + // 빈 곳과 찬 곳이 분명히 다르게 + color: JjColors.accent, + backgroundColor: JjColors.border, + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 150, + child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ]), + ); + return Column(mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent), + const SizedBox(width: 8), + Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(width: 12), + Expanded( + // Rsync 화면: 원본 → 대상 (⇄ 는 두 줄) + child: Text(rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest, + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))), + ]), + // Rsync 화면 (폴더 하나를 맞춤): 위 = 지금 복사하는 파일 (그 파일의 진행률 · 속도), 아래 = 폴더 전체 (파일 수) + if (rsync) ...[ + line( + job.counting + ? tr('파일 세는 중…') + : job.pruning + ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) + : job.finished + ? tr('끝') + : job.currentFile.isEmpty + ? tr('시작하는 중…') + : job.currentFile.split('/').last, + job.finished ? 1.0 : (job.filePercent ?? 0), + [ + if (job.filePercent != null && !job.finished) pct(job.filePercent!), + if (!job.finished && job.speed != null) '${formatSize(job.speed!.round())}/s', + ].join(' · '), + ), + line( + trf('{0} 전체', [job.currentName.isEmpty ? vBasename(job.sources.first) : job.currentName]), + job.folderProgress, + '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', + ), + ] else ...[ + // 파일 탐색기 (여러 항목): 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중 + line( + trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]), + job.overall, + '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}' + '${!job.finished && job.speed != null ? ' · ${formatSize(job.speed!.round())}/s' : ''}', + ), + line( + job.counting + ? tr('파일 세는 중…') + : job.pruning + ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) + : trf('{0} 안의 파일', [job.currentName]), + job.current, + '${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}', + ), + ], + ]); + } +} diff --git a/lib/ui/explorer/marks_bar.dart b/lib/ui/explorer/marks_bar.dart new file mode 100644 index 0000000..0ba960f --- /dev/null +++ b/lib/ui/explorer/marks_bar.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; + +import '../../core/explorer/tree_rows.dart' show globToRegExp; +import '../../core/file_ops.dart' show formatSize; +import '../../core/reader_sources.dart' show extOf; +import '../../core/vfs.dart' show vBasename; +import '../../l10n/tr.dart'; +import '../adaptive_row.dart'; +import '../theme.dart'; +import 'explorer_pane.dart'; +import 'explorer_select.dart'; + +/// 약속 (E5d-4): 선택 모드의 아래 막대 - 표시한 수 · 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) · +/// [고르기 도구 (반전 · 패턴 · 같은 확장자)] · [모두 선택] · [표시 지우기] · [선택 끝]. 고르는 일은 [ExplorerSelect]. +/// [listDir]: "폴더 + 파일 목록" 의 오른쪽 목록이면 그 폴더 (아니면 창의 지금 폴더) +class ExplorerMarksBar extends StatelessWidget { + const ExplorerMarksBar({super.key, required this.sel, required this.pane, this.listDir}); + + final ExplorerSelect sel; + final ExplorerPane pane; + final String? listDir; + + @override + Widget build(BuildContext context) { + final dir = listDir ?? pane.current; + // 74: 고른 개수와 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) + var size = 0, folders = 0; + final byPath = {for (final l in pane.cache.values) for (final x in l) x.path: x}; + for (final m in pane.sel.marked) { + final x = byPath[m]; + if (x == null) continue; + if (x.isDir) { + folders++; + } else { + size += x.size; + } + } + final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!); + final info = [ + Icon(Icons.checklist, size: 18, color: JjColors.accent), + const SizedBox(width: 6), + Flexible( + child: Text( + [ + trf('{0}개 표시함', [pane.sel.marked.length]), + if (size > 0) formatSize(size), + if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]), + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12)), + ), + ]; + final actions = [ + PopupMenuButton( + tooltip: tr('고르기 도구'), + onSelected: (v) { + switch (v) { + case 'invert': + sel.invertMarks(pane, dir); + case 'pattern': + markPattern(context, sel, pane, dir); + case 'sameExt': + sel.markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt); + } + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'invert', child: Text(tr('반전 (고른 것 ↔ 안 고른 것)'))), + PopupMenuItem(value: 'pattern', child: Text(tr('패턴으로 고르기 (예: *.srt)'))), + PopupMenuItem( + value: 'sameExt', + enabled: focusedExt.isNotEmpty, + child: Text(focusedExt.isEmpty ? tr('같은 확장자 모두') : trf('같은 확장자 모두 (.{0})', [focusedExt])), + ), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(tr('고르기 도구'), style: TextStyle(fontSize: 13, color: JjColors.accent)), + Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent), + ]), + ), + ), + TextButton( + onPressed: () => sel.markWhere(pane, listDir ?? pane.current, (_) => true), + child: Text(tr('모두 선택')), + ), + TextButton(onPressed: () => sel.clearMarks(pane), child: Text(tr('표시 지우기'))), + TextButton(onPressed: () => sel.setSelecting(false), child: Text(tr('선택 끝'))), + ]; + return Container( + color: JjColors.panel, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + // 좁은 창 (폰 세로의 두 창 - 규칙 7, 점검 10/22) 은 두 줄로 + child: AdaptiveRow(lead: info, trail: actions, minWidth: 560), + ); + } +} + +/// 74: 패턴으로 고르기 - 창에서 패턴을 받아 [dir] 에 보이는 것 중 맞는 것을 표시. 맞는 것이 없으면 알림 (Host) +Future markPattern(BuildContext context, ExplorerSelect sel, ExplorerPane pane, String dir) async { + final pattern = await showDialog(context: context, builder: (_) => const PatternDialog()); + if (pattern == null || pattern.trim().isEmpty) return; + final re = globToRegExp(pattern); + final before = pane.sel.marked.length; + sel.markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path))); + if (pane.sel.marked.length == before) sel.ctl.host.snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()])); +} + +/// 74: 패턴 넣기 +class PatternDialog extends StatefulWidget { + const PatternDialog({super.key}); + + @override + State createState() => PatternDialogState(); +} + +class PatternDialogState extends State { + final _ctl = TextEditingController(text: '*.srt'); + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(tr('패턴으로 고르기')), + content: TextField( + controller: _ctl, + autofocus: true, + decoration: InputDecoration(helperText: tr('* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)')), + onSubmitted: (v) => Navigator.pop(context, v), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(context, _ctl.text), child: Text(tr('고르기'))), + ], + ); +} diff --git a/lib/ui/explorer/toolbar.dart b/lib/ui/explorer/toolbar.dart new file mode 100644 index 0000000..bdaf2a6 --- /dev/null +++ b/lib/ui/explorer/toolbar.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/tr.dart'; +import '../theme.dart'; +import 'explorer_buttons.dart'; + +/// 약속 (E5d-4): 기능 버튼 줄 - 버튼 · 이름 · 아이콘 (창 배치 · 위아래면 "위 → 아래"), 다 들어가지 않으면 [더 보기 ⋯] (고정한 것은 늘 줄에). +/// 누르기 · 길게 누르기는 부르는 쪽 ([ExplorerButtons.press] · [ExplorerButtons.longPress]). 못 누르는 버튼은 흐리게 +/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때) +class ExplorerToolbar extends StatelessWidget { + final List buttons; + final bool vertical; + final void Function(ExplorerButton) onPressed; + final bool Function(ExplorerButton) enabled; + final bool Function(ExplorerButton) selected; + + /// 창이 둘인지 (두 창 · 폴더 + 파일 목록). 하나면 [orient] 는 "두 창으로" + final bool twoPanes; + + /// 좁아도 [더 보기] 로 보내지 않는 버튼 (창 배치 · 담아 둔 것이 있을 때 붙여넣기) + final Set pinned; + + /// X-plore b: 길게 누르기 = 짝 동작 (복사 ↔ 이동, 지우기 → 영구 삭제). 짝이 없는 버튼은 null + final void Function(ExplorerButton)? onLongPress; + const ExplorerToolbar( + {super.key, + required this.buttons, + required this.vertical, + required this.onPressed, + required this.enabled, + required this.selected, + this.onLongPress, + this.twoPanes = true, + this.pinned = const {ExplorerButton.orient}, + bool? panesSide}) + : panesSide = panesSide ?? vertical; + + /// 두 창이 좌우로 놓였는지 (버튼 이름 "좌 → 우" / "위 → 아래"). 보통 [vertical] 과 같고, + /// 191 좁은 Rsync 화면 (창 하나씩, 버튼 줄은 가로) 은 좌우 + final bool panesSide; + + /// 118: 지금 배치를 보여 준다 (누르면 반대로) + String _orientLabel() => !twoPanes ? '두 창으로' : panesSide ? '좌우 ⇆' : '위아래 ⇅'; + String _orientTip() => !twoPanes + ? '두 창으로 보기 (좌우)' + : panesSide + ? '창 배치: 좌우 (누르면 위아래)' + : '창 배치: 위아래 (누르면 좌우)'; + + /// 창이 위아래로 놓이면 (버튼 줄이 가로) "좌 → 우" 대신 "위 → 아래" + String _label(ExplorerButton b) => b == ExplorerButton.orient + ? _orientLabel() + : panesSide + ? b.label + : switch (b) { + ExplorerButton.toRight => '위 → 아래', + ExplorerButton.toLeft => '위 ← 아래', + ExplorerButton.both => '위 ⇄ 아래', + _ => b.label, + }; + + IconData _icon(ExplorerButton b) => b == ExplorerButton.orient + ? (!twoPanes ? Icons.vertical_split_outlined : panesSide ? Icons.swap_horiz : Icons.swap_vert) + : panesSide + ? b.icon + : switch (b) { + ExplorerButton.toRight => Icons.arrow_downward, + ExplorerButton.toLeft => Icons.arrow_upward, + ExplorerButton.both => Icons.swap_vert, + _ => b.icon, + }; + + @override + Widget build(BuildContext context) { + Widget btn(ExplorerButton b) { + final on = enabled(b); + final sel = selected(b); + final pair = ExplorerButtons.longPressLabel(b); + final long = onLongPress; + return Tooltip( + message: tr(b == ExplorerButton.orient ? _orientTip() : _label(b)) + + (pair != null && long != null ? ' · ${trf('길게 누르면 {0}', [tr(pair)])}' : ''), + child: InkWell( + onTap: on ? () => onPressed(b) : null, + onLongPress: on && pair != null && long != null ? () => long(b) : null, + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 72, + height: 58, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null), + const SizedBox(height: 2), + // 188: 두 줄로 꺾인 글 (일본어 "新しいフォルダー" 등) 이 버튼 아래로 잘렸다 - 남는 높이에 맞게 줄여 다 보이게 + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: SizedBox( + width: 68, + child: Text(tr(_label(b)), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)), + ), + ), + ), + ]), + ), + ), + ); + } + + // 147: 다 들어가지 않으면 (좁은 · 낮은 화면) 들어가는 만큼 보이고 나머지는 [더 보기 ⋯] 메뉴로 - 밀어야 보이는 것을 몰라 못 찾던 것 + Widget more(List rest) => Builder( + builder: (context) => Tooltip( + message: tr('더 보기'), + child: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () async { + final box = context.findRenderObject()! as RenderBox; + final at = box.localToGlobal(Offset.zero); + final pick = await showMenu( + context: context, + position: RelativeRect.fromLTRB(at.dx + box.size.width, at.dy, at.dx + box.size.width, at.dy), + items: [ + for (final b in rest) + PopupMenuItem( + value: b, + enabled: enabled(b), + height: 40, + child: Row(children: [ + Icon(_icon(b), size: 18, color: selected(b) ? JjColors.accent : null), + const SizedBox(width: 10), + Text(tr(_label(b))), + ]), + ), + ], + ); + if (pick != null) onPressed(pick); + }, + child: SizedBox( + width: 72, + height: 58, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon(Icons.more_horiz, size: 22), + const SizedBox(height: 2), + Text(tr('더 보기'), style: const TextStyle(fontSize: 10.5, height: 1.1)), + ]), + ), + ), + ), + ); + + return Container( + color: JjColors.panel, + width: vertical ? 76 : null, + height: vertical ? null : 62, + padding: const EdgeInsets.all(2), + child: LayoutBuilder(builder: (context, box) { + final room = vertical ? box.maxHeight : box.maxWidth; + final each = vertical ? 58.0 : 72.0; + var shown = buttons; + var rest = const []; + if (room.isFinite && buttons.length * each > room) { + final fit = (room / each).floor() - 1; // 하나는 [더 보기] 자리 + if (fit >= 1) { + shown = buttons.take(fit).toList(); + rest = buttons.skip(fit).toList(); + // 창 배치 버튼은 늘 줄에 (사용자가 가운데 아이콘 모음에 두라고 함, 10/9) - 넘치면 마지막 자리를 내준다. + // 73 (관리자): 복사 · 이동으로 담아 둔 것이 있으면 [붙여넣기] 도 (담아 두고 붙여넣기를 찾지 못하는 일이 없게) + for (final b in buttons.where((b) => pinned.contains(b) && rest.contains(b))) { + final i = shown.lastIndexWhere((x) => !pinned.contains(x)); + if (i < 0) break; + final out = shown[i]; + shown = [...shown.sublist(0, i), ...shown.sublist(i + 1), b]; + rest = [out, ...rest.where((x) => x != b)]; + } + } + } + final children = [for (final b in shown) btn(b), if (rest.isNotEmpty) more(rest)]; + return vertical ? Column(children: children) : Row(children: children); + }), + ); + } +} diff --git a/lib/ui/explorer/transfer_panel.dart b/lib/ui/explorer/transfer_panel.dart new file mode 100644 index 0000000..0ab5ed6 --- /dev/null +++ b/lib/ui/explorer/transfer_panel.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/transfer_job.dart'; +import '../../core/file_ops.dart' show FileOpCancelled; +import '../../l10n/tr.dart'; +import '../theme.dart'; +import 'job_view.dart'; + +/// 약속 (E5d-4): 아래에서 올라오는 복사 · 이동 진행 패널 (작업마다 [TransferJobView]). 작업이 없으면 내려가 숨는다. +/// 설정 explorerProgressHidden 이면 오른쪽 아래 작은 표시 ([TransferProgressChip], 멈춘 것이 있으면 빨갛게) - 누르면 다시 펼침 +class ExplorerTransferPanel extends StatelessWidget { + const ExplorerTransferPanel({super.key, required this.c, required this.jobs, required this.rsync}); + + final AppController c; + final List jobs; + + /// Rsync 화면 (작업 줄 모양이 다름) + final bool rsync; + + @override + Widget build(BuildContext context) { + // 85: 숨겼으면 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (누르면 다시 펼침) + if (jobs.isNotEmpty && c.settings.explorerProgressHidden) { + return Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ListenableBuilder( + listenable: Listenable.merge(jobs), + builder: (context, _) { + final run = [for (final j in jobs) if (!j.finished) j]; + // 관리자 판단: 숨겨도 멈춘 (실패한) 것은 분명히 - 빨간 표시 (끝난 알림 · 묻는 창은 숨김과 상관없이 늘 뜬다) + final failed = jobs.any((j) => j.finished && j.error != null && j.error is! FileOpCancelled); + final pct = run.isEmpty || run.any((j) => j.counting) + ? null + : (run.fold(0, (a, j) => a + j.overall) / run.length * 100).round(); + return TransferProgressChip( + running: run.length, + percent: pct, + failed: failed, + onTap: () => c.updateSettings((x) => x.explorerProgressHidden = false)); + }, + ), + ), + ); + } + return AnimatedSlide( + offset: jobs.isEmpty ? const Offset(0, 1.2) : Offset.zero, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + child: jobs.isEmpty + ? const SizedBox(height: 0) + : Material( + elevation: 12, + color: JjColors.panel, + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 12, 12), + // 여러 개가 함께 돌면 (23) 높이를 넘지 않게 넘겨 보기 + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.45), + child: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + // 85: 숨기기 (아래 작은 표시로) - 다른 파일을 보면서 기다릴 수 있게. Android 는 알림줄에도 진행률 + Align( + alignment: Alignment.centerRight, + child: SizedBox( + height: 28, + child: TextButton.icon( + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + onPressed: () => c.updateSettings((x) => x.explorerProgressHidden = true), + icon: const Icon(Icons.expand_more, size: 18), + label: Text(tr('숨기기'), style: const TextStyle(fontSize: 12)), + ), + ), + ), + for (final (k, job) in jobs.indexed) ...[ + if (k > 0) const Divider(height: 18), + ListenableBuilder(listenable: job, builder: (context, _) => TransferJobView(job: job, rsync: rsync)), + ], + ]), + ), + ), + ), + ), + ); + } +} + +/// 85: 진행 패널을 숨겼을 때 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (멈추면 빨갛게). 누르면 다시 펼침 +class TransferProgressChip extends StatelessWidget { + /// 도는 것 수 (0 이면 끝) · 진행률 (모르면 null) · 멈춘 (실패한) 것이 있는지 + final int running; + final int? percent; + final bool failed; + final VoidCallback onTap; + const TransferProgressChip({super.key, required this.running, this.percent, this.failed = false, required this.onTap}); + + @override + Widget build(BuildContext context) => Material( + elevation: 6, + color: failed ? JjColors.danger.withValues(alpha: 0.25) : JjColors.panelHigh, + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + // 멈춤은 진행 고리 대신 경고 표시 (꽉 찬 고리는 "끝났다" 로 보였다) + if (failed) + Icon(Icons.error_outline, size: 16, color: JjColors.danger) + else + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, value: running == 0 ? 1 : (percent == null ? null : percent! / 100)), + ), + const SizedBox(width: 8), + // 좁은 화면 · 큰 글자면 두 줄로 (412 · 글자 1.6배에서 화면을 넘었다 - 253 시험) + Flexible( + child: Text( + failed + ? tr('복사 · 이동 멈춤 - 눌러서 보기') + : running == 0 + ? tr('복사 · 이동 끝') + : percent == null + ? trf('복사 · 이동 {0}개', [running]) + : trf('복사 · 이동 {0}개 · {1}%', [running, percent]), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12.5), + ), + ), + const SizedBox(width: 4), + Tooltip(message: tr('진행 패널 보기'), child: const Icon(Icons.expand_less, size: 18)), + ]), + ), + ), + ); +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index ca644cc..f2b97b5 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1,5 +1,7 @@ export 'explorer/path_dialog.dart' show looksLikeSmb, smbToUnc; +export 'explorer/explorer_buttons.dart' show ExplorerButton; +export 'explorer/transfer_panel.dart' show TransferProgressChip; import 'dart:async'; import 'dart:collection'; import 'dart:io'; @@ -28,7 +30,6 @@ import '../platform/android/android_storage.dart'; import '../platform/android/hwp_pdf_ffi.dart' show RhwpEngine; import 'app_actions.dart'; import 'explorer_look.dart'; -import 'monitor_page.dart'; import 'player_page.dart'; import 'rsync_setup.dart'; import 'theme.dart'; @@ -55,7 +56,6 @@ import 'tail_text.dart'; import '../core/thumb_cache.dart' show thumbMemoryForgetters; import '../core/shizuku.dart'; import 'vault_page.dart' show openVault; -import 'app_manager_page.dart' show openAppManager; import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; import 'sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; import 'quick_look.dart'; @@ -75,9 +75,7 @@ import '../core/batch_rename.dart'; import 'notice.dart'; import 'confirm_dialog.dart'; import '../core/name_clash.dart'; -import 'adaptive_row.dart'; import '../app/volumes.dart'; -import '../core/explorer/tree_rows.dart'; import '../app/delete_ops.dart'; import '../app/transfer_plan.dart'; import '../core/file_clipboard.dart'; @@ -94,6 +92,10 @@ import 'explorer/explorer_select.dart'; import 'explorer/path_dialog.dart'; import 'explorer/pane_header.dart'; import 'explorer/pane_view.dart'; +import 'explorer/explorer_buttons.dart'; +import 'explorer/transfer_panel.dart'; +import 'explorer/marks_bar.dart'; +import 'explorer/toolbar.dart'; import 'explorer/server_flows.dart'; export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch; @@ -193,81 +195,7 @@ class ExplorerPage extends StatefulWidget { State createState() => _ExplorerPageState(); } -/// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다. -enum ExplorerButton { - up(Icons.arrow_upward, '상위 폴더'), - refresh(Icons.refresh, '새로 고침'), - select(Icons.checklist, '선택'), - // ── Rsync 화면에만 ── - toRight(Icons.arrow_forward, '좌 → 우'), - toLeft(Icons.arrow_back, '좌 ← 우'), - both(Icons.compare_arrows, '좌 ⇄ 우'), - monitor(Icons.monitor_heart_outlined, '모니터링'), - // ── - play(Icons.play_circle_outline, '재생'), - addMkv(Icons.playlist_add, 'MKV 목록에 추가'), - newFolder(Icons.create_new_folder_outlined, '새 폴더'), - rename(Icons.drive_file_rename_outline, '이름 변경'), - copy(Icons.copy_outlined, '복사'), - move(Icons.drive_file_move_outline, '이동'), - paste(Icons.content_paste, '붙여넣기'), - delete(Icons.delete_outline, '삭제'), - search(Icons.search, '찾기'), - sort(Icons.sort, '정렬 기준'), - hidden(Icons.visibility_outlined, '숨은 항목 표시'), - history(Icons.history, '내역'), - // 73: 즐겨찾기 (지금 폴더 넣기 · 한 번에 가기, Rsync 는 짝) - bookmarks(Icons.star_outline, '즐겨찾기'), - // 118: 두 창을 좌우 ⇆ / 위아래 ⇅ 로 한 번에 바꾸기 (한 창이면 두 창 (좌우) 으로) - orient(Icons.swap_horiz, '좌우 ⇆ / 위아래 ⇅'), - layout(Icons.view_quilt_outlined, '창 배치'), - buttons(Icons.tune, '버튼 구성'), - // 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일 - storage(Icons.pie_chart_outline, '저장 공간 분석'), - // 89: 지금 폴더를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용) - wifiShare(Icons.wifi_tethering, 'Wi-Fi 공유'), - // X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기) - trash(Icons.restore_from_trash_outlined, '휴지통'), - // X-plore 비교 5-2 (사용자 결정 10/21): 앱 관리자 (Android 만) - apps(Icons.apps, '앱 관리자'), - // X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함 (환경 설정 > 보안 에서 켰을 때 열림) - vault(Icons.enhanced_encryption_outlined, '암호 보관함'); - - final IconData icon; - final String label; - const ExplorerButton(this.icon, this.label); - - /// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기) - bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor; - - /// 파일 탐색기에서 고를 수 있는 버튼 - static List get explorerValues => [for (final b in values) if (!b.rsyncOnly && (b != apps || appsButtonSupported())) b]; - - /// 앱 관리자 버튼을 쓰는 기기 (Android - 시험에서 바꿈) - static bool Function() appsButtonSupported = () => Platform.isAndroid; - - /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) - // 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽 - static const rsyncButtons = [toRight, toLeft, both, monitor, up, refresh, newFolder, bookmarks]; - - /// 147 (관리자): 처음 값 (버튼 구성을 바꾸지 않았을 때) 은 자주 쓰는 것부터 - 좁은 화면에서 [더 보기] 로 가는 것은 뒤쪽 - static List get defaultOrder { - const first = [copy, move, delete, newFolder, rename, select, search, bookmarks, orient]; - return [...first, for (final b in explorerValues) if (!first.contains(b)) b]; - } - - static List fromSettings(List names) { - final out = [ - for (final n in names) - for (final b in explorerValues) - if (b.name == n) b, - ]; - // 사용자가 정한 순서는 그대로 (비어 있을 때만 처음 값) - return out.isEmpty ? defaultOrder : out; - } -} - -class _ExplorerPageState extends State implements ExplorerHost, ExplorerPaneParts { +class _ExplorerPageState extends State implements ExplorerHost, ExplorerPaneParts, ExplorerCommands { AppController get c => widget.c; /// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다 @@ -905,7 +833,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex servers: _servers, pane: pane, android: ExplorerPage.debugAndroidPaths ?? Platform.isAndroid, - onBookmarks: () => _bookmarks(pane), + onBookmarks: () => openBookmarks(pane), onOpenFilter: () => _openFilter(pane), onCloseFilter: () => _closeFilter(pane), onOpenRoot: () => _openRoot(pane), @@ -916,7 +844,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex List notices(ExplorerPane pane, String dir) => [_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)]; @override - Widget marksBar(ExplorerPane pane, {String? listDir}) => _marksBar(pane, listDir: listDir); + Widget marksBar(ExplorerPane pane, {String? listDir}) => ExplorerMarksBar(sel: _sel, pane: pane, listDir: listDir); @override void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _menu(pane, e, at); @override @@ -928,6 +856,9 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex @override void filterChanged() => setState(() {}); + /// 기능 버튼 → 할 일 (E5d-4) + late final _buttons = ExplorerButtons(_ctl, _sel, this); + /// 서버 추가 · 고치기 · 경로 입력 (E5d-3) late final _servers = ExplorerServers(_ctl); @@ -936,15 +867,6 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _open(path, pane: pane)) ..addListener(_onPane); - Future _markPattern(ExplorerPane pane, String dir) async { - final pattern = await showDialog(context: context, builder: (_) => const _PatternDialog()); - if (pattern == null || pattern.trim().isEmpty) return; - final re = globToRegExp(pattern); - final before = pane.sel.marked.length; - _sel.markWhere(pane, dir, (x) => re.hasMatch(vBasename(x.path))); - if (pane.sel.marked.length == before) _snack(trf('"{0}" 에 맞는 항목이 없습니다', [pattern.trim()])); - } - /// 다른 앱으로 열지 못했을 때: 음악 · 동영상이면 내장 플레이어를 알려 준다 (29) String _noAppMessage(String path) => isAudioFile(path) || isVideoFile(path) ? tr('이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.') @@ -966,7 +888,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex windows: Platform.isWindows, hwpEngine: RhwpEngine.available)) { case OpenRoute.player: - return _play([path]); + return play([path]); case OpenRoute.extractArchive: return _extractArchive(path, pane ?? _ctl.pane); case OpenRoute.images: @@ -1022,7 +944,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex Future _fetch(String path) => fetchDav(context, c, path); /// 동영상 재생 (WebDAV 는 받지 않고 바로 스트리밍) - Future _play(List paths, {bool internal = false, bool keepOrder = false}) async { + @override + Future play(List paths, {bool internal = false, bool keepOrder = false}) async { if (!mounted || paths.isEmpty) return; await playFiles(context, c, paths, internal: internal, keepOrder: keepOrder); } @@ -1150,7 +1073,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 /// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만). /// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다 - Future _storage(ExplorerPane pane, String dir) async { + @override + Future openStorage(ExplorerPane pane, String dir) async { setState(() => _ctl.active = _ctl.panes.indexOf(pane)); if (isDav(dir)) { final ok = await showDialog( @@ -1168,7 +1092,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } // X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로) final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async { - await _delete(pane, paths); + await deleteItems(pane, paths); return [for (final x in paths) if (!await vExists(x)) x]; }, onCut: (paths) => _toClipboard(paths, move: true)); if (path == null || !mounted) return; @@ -1180,7 +1104,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// 89: Wi-Fi 공유 화면 (이미 켜져 있으면 그 공유를 보인다). 이 기기의 폴더만 - WebDAV 등 원격은 알림 - Future _wifiShare(String dir) async { + @override + Future openWifiShare(String dir) async { final on = WifiShare.instance; if (on == null && isDav(dir)) { _snack(tr('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다')); @@ -1242,7 +1167,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex if (images.isEmpty) return; final dir = vDirname(images.first); final first = vBasename(images.first); - final name = await _askName(tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf'); + final name = await askName(tr('PDF 로 만들기'), '${first.contains('.') ? first.substring(0, first.lastIndexOf('.')) : first}.pdf'); if (name == null || name.trim().isEmpty) return; final out = vJoin(dir, name.toLowerCase().endsWith('.pdf') ? name : '$name.pdf'); final temp = await c.services.storage.tempDirectory(); @@ -1395,11 +1320,11 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex await _ctl.goTo(other, e.path); setState(() => _ctl.active = _ctl.panes.indexOf(other)); case 'playFolder': - await _playFolder(e.path); + await playFolder(e.path); case 'slideshow': await _slideshow(pane, e.path); case 'playInternal': - await _play([e.path], internal: true); + await play([e.path], internal: true); case 'grid': await _grid(pane, e.path); case 'text': @@ -1409,8 +1334,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex case 'vaultAdd': await openVault(context, c, add: targets, deleteOriginals: (paths, {required permanent}) { // 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더) - _shiftDelete = permanent; - return _delete(pane, paths); + shiftDelete = permanent; + return deleteItems(pane, paths); }); await _ctl.refreshAll([vDirname(e.path)]); case 'openWith': @@ -1420,7 +1345,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex final local = await _fetch(e.path); if (local != null && !await c.services.shell.openWith(local)) _snack(_noAppMessage(e.path)); case 'addMkv': - await _addMkv([e.path]); + await addMkv([e.path]); case 'range': _sel.markRange(pane, e); case 'select': @@ -1431,9 +1356,9 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex case 'extract': await _extractArchive(e.path, pane); case 'storage': - await _storage(pane, e.path); + await openStorage(pane, e.path); case 'wifiShare': - await _wifiShare(e.path); + await openWifiShare(e.path); case 'zip': final items = outermostPaths(_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); final other = _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null; @@ -1492,23 +1417,23 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } case 'rename': if (_sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) { - await _batchRename(pane, pane.sel.marked); + await batchRename(pane, pane.sel.marked); } else { - await _rename(pane, e.path); + await renameItem(pane, e.path); } case 'copy': - await _transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); + await transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'move': - await _transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); + await transfer(pane, _sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'clipCopy': _toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'clipMove': _toClipboard(_sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'paste': - await _paste(pane, e.isDir ? e.path : vDirname(e.path)); + await paste(pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': // 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다) - await _delete(pane, targets); + await deleteItems(pane, targets); case 'share': await _share(targets); case 'copyPath': @@ -1586,7 +1511,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex /// 압축 안을 바꾸려 하면 막고 알린다 (읽기 전용). 막았으면 true /// [self] 면 압축 파일 자신도 (그 안으로 넣는 곳일 때) - bool _blockArchive(List paths, {bool self = false}) { + @override + bool blockArchive(List paths, {bool self = false}) { // X-plore 비교 5-2: 시스템 폴더 (Shizuku) 도 읽기 전용 - 쓰기 · 지우기 · 이름 바꾸기 · 옮기기 · 새 폴더 · 넣기를 막는다 (꺼내기만) if (paths.any(isShizuku)) { _snack(paths.any(isRootPath) @@ -1648,7 +1574,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고) - List _targets(ExplorerPane pane) { + @override + List targetsOf(ExplorerPane pane) { if (!pane.sel.isEmpty) return pane.sel.marked; final f = pane.focused; if (f == null || samePath(f, pane.root)) return const []; @@ -1660,134 +1587,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } - Future _buttonLong(ExplorerButton b) async { - switch (b) { - case ExplorerButton.copy: - return _button(ExplorerButton.move); - case ExplorerButton.move: - return _button(ExplorerButton.copy); - case ExplorerButton.delete: - _shiftDelete = true; - return _button(ExplorerButton.delete); - default: - return; - } - } - - Future _button(ExplorerButton b) async { - final pane = _ctl.pane; - switch (b) { - case ExplorerButton.up: - if (samePath(pane.current, pane.root)) return; - final up = vDirname(pane.current); - pane.expanded.remove(pane.current); - await _ctl.goTo(pane, up); - case ExplorerButton.select: - _sel.setSelecting(!_sel.selecting); - case ExplorerButton.monitor: - await MonitorPage.open(context, c); - case ExplorerButton.toRight: - await _runRsync(right: true, left: false); - case ExplorerButton.toLeft: - await _runRsync(right: false, left: true); - case ExplorerButton.both: - await _runRsync(right: true, left: true); - case ExplorerButton.refresh: - // 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게) - await _ctl.reloadOpen(); - case ExplorerButton.play: - final t = _targets(pane); - // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) - final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList(); - if (videos.isNotEmpty) { - await _play(videos, keepOrder: !pane.sel.isEmpty); - } else { - await _playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); - } - case ExplorerButton.addMkv: - final t = _targets(pane); - await _addMkv(t.isEmpty ? [pane.current] : t); - case ExplorerButton.newFolder: - // X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다 - if (_blockArchive([pane.current], self: true)) return; - final name = await _askName(tr('새 폴더'), ''); - if (name == null) return; - try { - final parent = pane.current; - final made = await FileOps.makeFolder(parent, name); - // 만든 폴더가 트리에 보이게: 부모를 펼치고 다시 읽은 뒤 그 줄까지 스크롤 - pane.expanded.add(parent); - await _ctl.load(pane, parent, force: true); - await _ctl.refreshAll([parent]); - // Rsync 화면: 만든 폴더를 이 창의 원본 · 대상으로 바로 고른다 - if (widget.rsync) { - pane.sel.clear(); - await pane.sel.toggle(made, isDir: true, list: (_) async => const []); - } - pane.focused = made; - pane.changed(); - _reveal(pane, made); - } catch (e) { - _snack(trf('폴더를 만들 수 없습니다: {0}', [e])); - } - case ExplorerButton.rename: - final t = _targets(pane); - if (t.isEmpty) { - _snack(tr('이름을 바꿀 항목 하나를 고르세요.')); - return; - } - // 78: 여럿을 골랐으면 일괄 이름 바꾸기 - if (t.length > 1) return _batchRename(pane, t); - await _rename(pane, t.first); - case ExplorerButton.copy: - await _transfer(pane, _targets(pane), move: false); - case ExplorerButton.move: - await _transfer(pane, _targets(pane), move: true); - case ExplorerButton.paste: - await _paste(pane, pane.current); - case ExplorerButton.delete: - await _delete(pane, _targets(pane)); - case ExplorerButton.search: - await _search(pane); - case ExplorerButton.storage: - await _storage(pane, pane.current); - case ExplorerButton.wifiShare: - await _wifiShare(pane.current); - case ExplorerButton.apps: - await openAppManager(context); - case ExplorerButton.vault: - if (!c.settings.vaultEnabled) { - _snack(tr('암호 보관함은 환경 설정 > 보안 에서 켭니다 (처음 꺼짐)')); - } else { - await openVault(context, c); - } - case ExplorerButton.trash: - await _openTrash(); - case ExplorerButton.sort: - await _sortDialog(); - case ExplorerButton.hidden: - await c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden); - for (final x in _ctl.panes) { - x.cache.clear(); - for (final d in x.expanded.toList()) { - await _ctl.load(x, d, force: true); - } - } - _snack(c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.')); - case ExplorerButton.history: - await _historyDialog(pane); - case ExplorerButton.bookmarks: - await _bookmarks(pane); - case ExplorerButton.orient: - await _toggleOrientation(); - case ExplorerButton.layout: - await _layoutDialog(); - case ExplorerButton.buttons: - await _buttonsDialog(); - } - } - - Future _playFolder(String dir) async { + @override + Future playFolder(String dir) async { final List list; try { list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList(); @@ -1800,10 +1601,11 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex return; } if (!mounted) return; - await _play(list); + await play(list); } - Future _addMkv(List paths) async { + @override + Future addMkv(List paths) async { if (paths.any(isDav)) { _snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.')); return; @@ -1830,9 +1632,10 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex _snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length])); } - Future _rename(ExplorerPane pane, String path) async { - if (_blockArchive([path])) return; - final name = await _askName(tr('이름 변경'), vBasename(path)); + @override + Future renameItem(ExplorerPane pane, String path) async { + if (blockArchive([path])) return; + final name = await askName(tr('이름 변경'), vBasename(path)); if (name == null || name == vBasename(path)) return; try { final now = await FileOps.rename(path, name); @@ -1849,8 +1652,9 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] - Future _batchRename(ExplorerPane pane, List paths) async { - if (_blockArchive(paths)) return; + @override + Future batchRename(ExplorerPane pane, List paths) async { + if (blockArchive(paths)) return; int at(String x) { final i = (pane.cache[vDirname(x)] ?? const []).indexWhere((e) => samePath(e.path, x)); return i < 0 ? 1 << 30 : i; @@ -1896,15 +1700,17 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// Shift+Delete 로 불렀는지 (휴지통을 거치지 않고 지우기를 미리 골라 둔다 - 65) - bool _shiftDelete = false; + @override + bool shiftDelete = false; /// 지우기. Windows 의 로컬 파일은 기본으로 휴지통으로 (창에서 "영구 삭제" 를 고르거나 Shift+Delete). /// WebDAV · Android 는 휴지통이 없어 바로 지운다. 여러 개면 실패해도 나머지를 계속하고, 못 지운 것을 알린다 (65). - Future _delete(ExplorerPane pane, List paths) async { - if (_blockArchive(paths)) return; + @override + Future deleteItems(ExplorerPane pane, List paths) async { + if (blockArchive(paths)) return; paths = outermostPaths(paths); // 79-c: 고른 폴더 안의 것은 한 번만 (확인 창 개수도) - final shift = _shiftDelete; - _shiftDelete = false; + final shift = shiftDelete; + shiftDelete = false; if (paths.isEmpty) { _snack(tr('지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).')); return; @@ -2109,10 +1915,11 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex /// 시험: [휴지통] 열기 @visibleForTesting - Future debugOpenTrash() => _openTrash(); + Future debugOpenTrash() => openTrash(); /// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통 - Future _openTrash() async { + @override + Future openTrash() async { final trash = _appTrash; if (trash == null) { if (Platform.isWindows) { @@ -2143,12 +1950,13 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) /// 복사 · 이동 버튼: 두 창이면 다른 창의 지금 폴더로, 그 밖의 배치는 담아 두기 (붙여넣기로) - Future _transfer(ExplorerPane pane, List sources, {required bool move}) async { + @override + Future transfer(ExplorerPane pane, List sources, {required bool move}) async { if (_blockShizukuMove(sources, null, move: move)) return; // X-plore 비교 f: 압축 안의 것 - 복사는 꺼내기 (86 의 풀기 검사 그대로), 이동은 막음 if (sources.any(_insideArchive)) { if (move) { - _blockArchive(sources); + blockArchive(sources); return; } await _extractFromArchive(sources, _other?.current); @@ -2182,7 +1990,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex [namesOf(sources), move ? tr('이동') : tr('복사')])); } - Future _paste(ExplorerPane pane, String dest) async { + @override + Future paste(ExplorerPane pane, String dest) async { if (_clip.isEmpty) { _snack(tr('붙여넣을 항목이 없습니다. 먼저 [복사] · [이동] 으로 담으세요.')); return; @@ -2256,9 +2065,9 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } if (_blockShizukuMove(sources, dest, move: move)) return false; // X-plore 비교 f: 압축 안으로는 넣지 않고 (읽기 전용), 압축 안의 것을 붙여넣으면 꺼내기로 - if (_blockArchive([dest], self: true)) return false; + if (blockArchive([dest], self: true)) return false; if (sources.any(_insideArchive)) { - if (move) return !_blockArchive(sources); + if (move) return !blockArchive(sources); await _extractFromArchive(sources, dest); return true; } @@ -2494,7 +2303,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex /// 확인 창이 떠 있는 동안 또 누르면 무시 (창이 겹쳐 아래 창을 잘못 누르지 않게 - 110) bool _rsyncAsking = false; - Future _runRsync({required bool right, required bool left}) async { + @override + Future runRsync({required bool right, required bool left}) async { if (_rsyncAsking) return; _rsyncAsking = true; try { @@ -2568,173 +2378,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex if (mounted) setState(() => _jobs = [for (final j in _jobs) if (!jobs.contains(j)) j]); } - /// 아래에서 올라오는 진행 막대 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중) - Widget _transferPanel() { - final jobs = _jobs; - // 85: 숨겼으면 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (누르면 다시 펼침) - if (jobs.isNotEmpty && c.settings.explorerProgressHidden) { - return Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ListenableBuilder( - listenable: Listenable.merge(jobs), - builder: (context, _) { - final run = [for (final j in jobs) if (!j.finished) j]; - // 관리자 판단: 숨겨도 멈춘 (실패한) 것은 분명히 - 빨간 표시 (끝난 알림 · 묻는 창은 숨김과 상관없이 늘 뜬다) - final failed = jobs.any((j) => j.finished && j.error != null && j.error is! FileOpCancelled); - final pct = run.isEmpty || run.any((j) => j.counting) - ? null - : (run.fold(0, (a, j) => a + j.overall) / run.length * 100).round(); - return TransferProgressChip( - running: run.length, - percent: pct, - failed: failed, - onTap: () => c.updateSettings((x) => x.explorerProgressHidden = false)); - }, - ), - ), - ); - } - return AnimatedSlide( - offset: jobs.isEmpty ? const Offset(0, 1.2) : Offset.zero, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - child: jobs.isEmpty - ? const SizedBox(height: 0) - : Material( - elevation: 12, - color: JjColors.panel, - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 12, 12), - // 여러 개가 함께 돌면 (23) 높이를 넘지 않게 넘겨 보기 - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.45), - child: SingleChildScrollView( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - // 85: 숨기기 (아래 작은 표시로) - 다른 파일을 보면서 기다릴 수 있게. Android 는 알림줄에도 진행률 - Align( - alignment: Alignment.centerRight, - child: SizedBox( - height: 28, - child: TextButton.icon( - style: TextButton.styleFrom(visualDensity: VisualDensity.compact), - onPressed: () => c.updateSettings((x) => x.explorerProgressHidden = true), - icon: const Icon(Icons.expand_more, size: 18), - label: Text(tr('숨기기'), style: const TextStyle(fontSize: 12)), - ), - ), - ), - for (final (k, job) in jobs.indexed) ...[ - if (k > 0) const Divider(height: 18), - ListenableBuilder(listenable: job, builder: (context, _) => _jobView(job)), - ], - ]), - ), - ), - ), - ), - ); - } - - /// 진행 막대 하나 (위: 전체 항목 중 · 아래: 지금 폴더의 파일 중) - Widget _jobView(TransferJob job) { - String pct(double v) => '${(v * 100).round()}%'; - final cur = job.index < job.total ? job.index : job.total - 1; - final curFiles = job.total == 0 ? 0 : job.filesTotal[cur.clamp(0, job.total - 1)]; - final curDone = job.total == 0 ? 0 : job.filesDone[cur.clamp(0, job.total - 1)]; - final title = job.finished - ? (job.error == null ? tr('완료') : tr('멈춤')) - : job.move - ? tr('옮기는 중') - : tr('복사하는 중'); - final labelMax = MediaQuery.sizeOf(context).width * 0.45; - Widget line(String label, double value, String right) => Padding( - padding: const EdgeInsets.only(top: 6), - child: Row(children: [ - SizedBox( - width: math.min(300, labelMax), // 좁은 화면 (폰 세로) 은 폭의 45% 까지 - child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), - ), - const SizedBox(width: 10), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - minHeight: 8, - value: job.counting ? null : value, - // 빈 곳과 찬 곳이 분명히 다르게 - color: JjColors.accent, - backgroundColor: JjColors.border, - ), - ), - ), - const SizedBox(width: 10), - SizedBox( - width: 150, - child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - ]), - ); - return Column(mainAxisSize: MainAxisSize.min, children: [ - Row(children: [ - Icon(job.move ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 18, color: JjColors.accent), - const SizedBox(width: 8), - Text('$title · ${tr(job.method.label)}', style: const TextStyle(fontWeight: FontWeight.w600)), - const SizedBox(width: 12), - Expanded( - // Rsync 화면: 원본 → 대상 (⇄ 는 두 줄) - child: Text(widget.rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest, - maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))), - ]), - // Rsync 화면 (폴더 하나를 맞춤): 위 = 지금 복사하는 파일 (그 파일의 진행률 · 속도), 아래 = 폴더 전체 (파일 수) - if (widget.rsync) ...[ - line( - job.counting - ? tr('파일 세는 중…') - : job.pruning - ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) - : job.finished - ? tr('끝') - : job.currentFile.isEmpty - ? tr('시작하는 중…') - : job.currentFile.split('/').last, - job.finished ? 1.0 : (job.filePercent ?? 0), - [ - if (job.filePercent != null && !job.finished) pct(job.filePercent!), - if (!job.finished && job.speed != null) '${formatSize(job.speed!.round())}/s', - ].join(' · '), - ), - line( - trf('{0} 전체', [job.currentName.isEmpty ? vBasename(job.sources.first) : job.currentName]), - job.folderProgress, - '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', - ), - ] else ...[ - // 파일 탐색기 (여러 항목): 위 = 전체 항목 중 · 아래 = 지금 폴더의 파일 중 - line( - trf('전체 {0}개 중 {1}번째', [job.total, (cur + 1).clamp(1, job.total)]), - job.overall, - '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}' - '${!job.finished && job.speed != null ? ' · ${formatSize(job.speed!.round())}/s' : ''}', - ), - line( - job.counting - ? tr('파일 세는 중…') - : job.pruning - ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) - : trf('{0} 안의 파일', [job.currentName]), - job.current, - '${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}', - ), - ], - ]); - } - - Future _askName(String title, String initial, {String? hint, bool selectAll = false}) { + @override + Future askName(String title, String initial, {String? hint, bool selectAll = false}) { final ctl = TextEditingController(text: initial); final dot = initial.lastIndexOf('.'); ctl.selection = TextSelection(baseOffset: 0, extentOffset: dot > 0 && !selectAll ? dot : initial.length); @@ -2790,7 +2435,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex ({ExplorerPane pane, String root, SearchConditions cond, List found, bool done, Map moved})? _searchBack; /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) - Future _search(ExplorerPane pane) async { + @override + Future openSearch(ExplorerPane pane) async { setState(() => _ctl.active = _ctl.panes.indexOf(pane)); final cond = await askSearchConditions(context, pane.current, _lastSearch); if (cond == null || !mounted) return; @@ -2870,11 +2516,11 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex _reveal(pane, path); // 고른 것만 대상 (154 · 155) - 탐색기의 복사 · 이동 · 지우기와 같은 확인 · 진행 패널 · 휴지통 case 'copy': - await _transfer(pane, act.paths, move: false); + await transfer(pane, act.paths, move: false); case 'move': - await _transfer(pane, act.paths, move: true); + await transfer(pane, act.paths, move: true); case 'delete': - await _delete(pane, act.paths); + await deleteItems(pane, act.paths); } } @@ -2983,7 +2629,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex _snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.')); } - Future _sortDialog() async { + @override + Future sortDialog() async { // 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬 final dir = _ctl.pane.current; var (by, desc) = _ctl.sortOf(dir); @@ -3019,7 +2666,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에 - Future _bookmarks(ExplorerPane pane) async { + @override + Future openBookmarks(ExplorerPane pane) async { setState(() => _ctl.active = _ctl.panes.indexOf(pane)); await showFolderBookmarks( context, @@ -3037,7 +2685,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex if (mounted) setState(() {}); } - Future _historyDialog(ExplorerPane pane) async { + @override + Future historyDialog(ExplorerPane pane) async { final hist = c.settings.explorerHistory.where(reachable).toList(); final pick = await showDialog( context: context, @@ -3057,7 +2706,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// 118: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로 - Future _toggleOrientation() async { + @override + Future toggleOrientation() async { final s = c.settings; if (!(_dual || _split)) { await c.updateSettings((x) => x @@ -3080,7 +2730,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } } - Future _layoutDialog() async { + @override + Future layoutDialog() async { final s = c.settings; var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar; final ok = await showDialog( @@ -3155,7 +2806,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } } - Future _buttonsDialog() async { + @override + Future buttonsDialog() async { final current = ExplorerButton.fromSettings(c.settings.explorerButtons); final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))]; final on = {...current}; @@ -3227,7 +2879,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex return; } if (!samePath(_ctl.pane.current, _ctl.pane.root)) { - await _button(ExplorerButton.up); + await _buttons.press(ExplorerButton.up); return; } if (mounted) Navigator.of(context).pop(); @@ -3306,7 +2958,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex child: _body()) : _body()), // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 - Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()), + Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _jobs, rsync: widget.rsync)), ]), ), ]); @@ -3368,7 +3020,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex PopupMenuButton( tooltip: tr('창 배치 · 버튼 구성'), icon: const Icon(Icons.more_vert), - onSelected: (v) => v is ExplorerButton ? _button(v) : _ctl.toggleAppHidden(), + onSelected: (v) => v is ExplorerButton ? _buttons.press(v) : _ctl.toggleAppHidden(), itemBuilder: (_) => [ for (final b in [ if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons], @@ -3441,14 +3093,14 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex _narrowSplit = split && side && box.maxWidth < narrowSplitWidth; final bar = s.explorerToolbar == 'hidden' && !widget.rsync ? null - : _Toolbar( + : ExplorerToolbar( // X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음) - onLongPress: widget.rsync ? null : _buttonLong, + onLongPress: widget.rsync ? null : _buttons.longPress, // Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에 buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons), vertical: side && !rsyncOne, panesSide: side, - onPressed: _button, + onPressed: _buttons.press, enabled: (b) => switch (b) { ExplorerButton.paste => _clip.isNotEmpty, _ => true, @@ -3673,84 +3325,6 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex } /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 - Widget _marksBar(ExplorerPane pane, {String? listDir}) { - final dir = listDir ?? pane.current; - // 74: 고른 개수와 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) - var size = 0, folders = 0; - final byPath = {for (final l in pane.cache.values) for (final x in l) x.path: x}; - for (final m in pane.sel.marked) { - final x = byPath[m]; - if (x == null) continue; - if (x.isDir) { - folders++; - } else { - size += x.size; - } - } - final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!); - final info = [ - Icon(Icons.checklist, size: 18, color: JjColors.accent), - const SizedBox(width: 6), - Flexible( - child: Text( - [ - trf('{0}개 표시함', [pane.sel.marked.length]), - if (size > 0) formatSize(size), - if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]), - ].join(' · '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12)), - ), - ]; - final actions = [ - PopupMenuButton( - tooltip: tr('고르기 도구'), - onSelected: (v) { - switch (v) { - case 'invert': - _sel.invertMarks(pane, dir); - case 'pattern': - _markPattern(pane, dir); - case 'sameExt': - _sel.markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt); - } - }, - itemBuilder: (_) => [ - PopupMenuItem(value: 'invert', child: Text(tr('반전 (고른 것 ↔ 안 고른 것)'))), - PopupMenuItem(value: 'pattern', child: Text(tr('패턴으로 고르기 (예: *.srt)'))), - PopupMenuItem( - value: 'sameExt', - enabled: focusedExt.isNotEmpty, - child: Text(focusedExt.isEmpty ? tr('같은 확장자 모두') : trf('같은 확장자 모두 (.{0})', [focusedExt])), - ), - ], - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text(tr('고르기 도구'), style: TextStyle(fontSize: 13, color: JjColors.accent)), - Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent), - ]), - ), - ), - TextButton( - onPressed: () => setState(() { - final dir = listDir ?? pane.current; - pane.sel.addAll([for (final x in _sel.shown(pane, dir)) x.path]); - }), - child: Text(tr('모두 선택')), - ), - TextButton(onPressed: () => setState(pane.sel.clear), child: Text(tr('표시 지우기'))), - TextButton(onPressed: () => _sel.setSelecting(false), child: Text(tr('선택 끝'))), - ]; - return Container( - color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - // 좁은 창 (폰 세로의 두 창 - 규칙 7, 점검 10/22) 은 두 줄로 - child: AdaptiveRow(lead: info, trail: actions, minWidth: 560), - ); - } - /// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작) void _openFilter(ExplorerPane pane, [String? text]) { setState(() { @@ -3784,22 +3358,22 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex /// 사용자가 바꾼 것은 설정 explorerKeyOverrides. Rsync 화면은 늘 같은 키 (바꾼 것을 쓰지 않음) Map _keyBindings() { final cb = { - 'copy': () => _button(ExplorerButton.copy), - 'move': () => _button(ExplorerButton.move), - 'newFolder': () => _button(ExplorerButton.newFolder), - 'delete': () => _button(ExplorerButton.delete), + 'copy': () => _buttons.press(ExplorerButton.copy), + 'move': () => _buttons.press(ExplorerButton.move), + 'newFolder': () => _buttons.press(ExplorerButton.newFolder), + 'delete': () => _buttons.press(ExplorerButton.delete), // 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인) 'deletePermanent': () { - _shiftDelete = true; - _button(ExplorerButton.delete); + shiftDelete = true; + _buttons.press(ExplorerButton.delete); }, - 'rename': () => _button(ExplorerButton.rename), - 'refresh': () => _button(ExplorerButton.refresh), + 'rename': () => _buttons.press(ExplorerButton.rename), + 'refresh': () => _buttons.press(ExplorerButton.refresh), // 81: 빠른 보기 'quickLook': () => _quickLook(_ctl.pane), - 'clipCopy': () => _toClipboard(_targets(_ctl.pane), move: false), - 'clipCut': () => _toClipboard(_targets(_ctl.pane), move: true), - 'paste': () => _paste(_ctl.pane, _ctl.pane.current), + 'clipCopy': () => _toClipboard(targetsOf(_ctl.pane), move: false), + 'clipCut': () => _toClipboard(targetsOf(_ctl.pane), move: true), + 'paste': () => paste(_ctl.pane, _ctl.pane.current), 'selectAll': () { if (!_sel.selecting) setState(() => _sel.selecting = true); _sel.markWhere(_ctl.pane, _ctl.pane.current, (_) => true); @@ -3808,7 +3382,7 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex 'mark': () => _markFocused(next: false), 'markNext': () => _markFocused(next: true), 'filter': () => _openFilter(_ctl.panes[_ctl.active]), - 'up': () => _button(ExplorerButton.up), + 'up': () => _buttons.press(ExplorerButton.up), // 75: 지금 창의 뒤로 · 앞으로 'back': () => _ctl.travel(_ctl.panes[_ctl.active], forward: false), 'forward': () => _ctl.travel(_ctl.panes[_ctl.active], forward: true), @@ -3902,278 +3476,6 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex String _date(DateTime d) => fileDateText(d); -/// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때) -class _Toolbar extends StatelessWidget { - final List buttons; - final bool vertical; - final void Function(ExplorerButton) onPressed; - final bool Function(ExplorerButton) enabled; - final bool Function(ExplorerButton) selected; - - /// 창이 둘인지 (두 창 · 폴더 + 파일 목록). 하나면 [orient] 는 "두 창으로" - final bool twoPanes; - - /// 좁아도 [더 보기] 로 보내지 않는 버튼 (창 배치 · 담아 둔 것이 있을 때 붙여넣기) - final Set pinned; - - /// X-plore b: 길게 누르기 = 짝 동작 (복사 ↔ 이동, 지우기 → 영구 삭제). 짝이 없는 버튼은 null - final void Function(ExplorerButton)? onLongPress; - const _Toolbar( - {required this.buttons, - required this.vertical, - required this.onPressed, - required this.enabled, - required this.selected, - this.onLongPress, - this.twoPanes = true, - this.pinned = const {ExplorerButton.orient}, - bool? panesSide}) - : panesSide = panesSide ?? vertical; - - /// 두 창이 좌우로 놓였는지 (버튼 이름 "좌 → 우" / "위 → 아래"). 보통 [vertical] 과 같고, - /// 191 좁은 Rsync 화면 (창 하나씩, 버튼 줄은 가로) 은 좌우 - final bool panesSide; - - /// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null) - static String? longPressLabel(ExplorerButton b) => switch (b) { - ExplorerButton.copy => '이동', - ExplorerButton.move => '복사', - ExplorerButton.delete => '영구 삭제', - _ => null, - }; - - /// 118: 지금 배치를 보여 준다 (누르면 반대로) - String _orientLabel() => !twoPanes ? '두 창으로' : panesSide ? '좌우 ⇆' : '위아래 ⇅'; - String _orientTip() => !twoPanes - ? '두 창으로 보기 (좌우)' - : panesSide - ? '창 배치: 좌우 (누르면 위아래)' - : '창 배치: 위아래 (누르면 좌우)'; - - /// 창이 위아래로 놓이면 (버튼 줄이 가로) "좌 → 우" 대신 "위 → 아래" - String _label(ExplorerButton b) => b == ExplorerButton.orient - ? _orientLabel() - : panesSide - ? b.label - : switch (b) { - ExplorerButton.toRight => '위 → 아래', - ExplorerButton.toLeft => '위 ← 아래', - ExplorerButton.both => '위 ⇄ 아래', - _ => b.label, - }; - - IconData _icon(ExplorerButton b) => b == ExplorerButton.orient - ? (!twoPanes ? Icons.vertical_split_outlined : panesSide ? Icons.swap_horiz : Icons.swap_vert) - : panesSide - ? b.icon - : switch (b) { - ExplorerButton.toRight => Icons.arrow_downward, - ExplorerButton.toLeft => Icons.arrow_upward, - ExplorerButton.both => Icons.swap_vert, - _ => b.icon, - }; - - @override - Widget build(BuildContext context) { - Widget btn(ExplorerButton b) { - final on = enabled(b); - final sel = selected(b); - final pair = longPressLabel(b); - final long = onLongPress; - return Tooltip( - message: tr(b == ExplorerButton.orient ? _orientTip() : _label(b)) + - (pair != null && long != null ? ' · ${trf('길게 누르면 {0}', [tr(pair)])}' : ''), - child: InkWell( - onTap: on ? () => onPressed(b) : null, - onLongPress: on && pair != null && long != null ? () => long(b) : null, - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: 72, - height: 58, - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null), - const SizedBox(height: 2), - // 188: 두 줄로 꺾인 글 (일본어 "新しいフォルダー" 등) 이 버튼 아래로 잘렸다 - 남는 높이에 맞게 줄여 다 보이게 - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: SizedBox( - width: 68, - child: Text(tr(_label(b)), - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)), - ), - ), - ), - ]), - ), - ), - ); - } - - // 147: 다 들어가지 않으면 (좁은 · 낮은 화면) 들어가는 만큼 보이고 나머지는 [더 보기 ⋯] 메뉴로 - 밀어야 보이는 것을 몰라 못 찾던 것 - Widget more(List rest) => Builder( - builder: (context) => Tooltip( - message: tr('더 보기'), - child: InkWell( - borderRadius: BorderRadius.circular(6), - onTap: () async { - final box = context.findRenderObject()! as RenderBox; - final at = box.localToGlobal(Offset.zero); - final pick = await showMenu( - context: context, - position: RelativeRect.fromLTRB(at.dx + box.size.width, at.dy, at.dx + box.size.width, at.dy), - items: [ - for (final b in rest) - PopupMenuItem( - value: b, - enabled: enabled(b), - height: 40, - child: Row(children: [ - Icon(_icon(b), size: 18, color: selected(b) ? JjColors.accent : null), - const SizedBox(width: 10), - Text(tr(_label(b))), - ]), - ), - ], - ); - if (pick != null) onPressed(pick); - }, - child: SizedBox( - width: 72, - height: 58, - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.more_horiz, size: 22), - const SizedBox(height: 2), - Text(tr('더 보기'), style: const TextStyle(fontSize: 10.5, height: 1.1)), - ]), - ), - ), - ), - ); - - return Container( - color: JjColors.panel, - width: vertical ? 76 : null, - height: vertical ? null : 62, - padding: const EdgeInsets.all(2), - child: LayoutBuilder(builder: (context, box) { - final room = vertical ? box.maxHeight : box.maxWidth; - final each = vertical ? 58.0 : 72.0; - var shown = buttons; - var rest = const []; - if (room.isFinite && buttons.length * each > room) { - final fit = (room / each).floor() - 1; // 하나는 [더 보기] 자리 - if (fit >= 1) { - shown = buttons.take(fit).toList(); - rest = buttons.skip(fit).toList(); - // 창 배치 버튼은 늘 줄에 (사용자가 가운데 아이콘 모음에 두라고 함, 10/9) - 넘치면 마지막 자리를 내준다. - // 73 (관리자): 복사 · 이동으로 담아 둔 것이 있으면 [붙여넣기] 도 (담아 두고 붙여넣기를 찾지 못하는 일이 없게) - for (final b in buttons.where((b) => pinned.contains(b) && rest.contains(b))) { - final i = shown.lastIndexWhere((x) => !pinned.contains(x)); - if (i < 0) break; - final out = shown[i]; - shown = [...shown.sublist(0, i), ...shown.sublist(i + 1), b]; - rest = [out, ...rest.where((x) => x != b)]; - } - } - } - final children = [for (final b in shown) btn(b), if (rest.isNotEmpty) more(rest)]; - return vertical ? Column(children: children) : Row(children: children); - }), - ); - } -} - -/// 85: 진행 패널을 숨겼을 때 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (멈추면 빨갛게). 누르면 다시 펼침 -class TransferProgressChip extends StatelessWidget { - /// 도는 것 수 (0 이면 끝) · 진행률 (모르면 null) · 멈춘 (실패한) 것이 있는지 - final int running; - final int? percent; - final bool failed; - final VoidCallback onTap; - const TransferProgressChip({super.key, required this.running, this.percent, this.failed = false, required this.onTap}); - - @override - Widget build(BuildContext context) => Material( - elevation: 6, - color: failed ? JjColors.danger.withValues(alpha: 0.25) : JjColors.panelHigh, - borderRadius: BorderRadius.circular(18), - child: InkWell( - borderRadius: BorderRadius.circular(18), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - // 멈춤은 진행 고리 대신 경고 표시 (꽉 찬 고리는 "끝났다" 로 보였다) - if (failed) - Icon(Icons.error_outline, size: 16, color: JjColors.danger) - else - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2, value: running == 0 ? 1 : (percent == null ? null : percent! / 100)), - ), - const SizedBox(width: 8), - // 좁은 화면 · 큰 글자면 두 줄로 (412 · 글자 1.6배에서 화면을 넘었다 - 253 시험) - Flexible( - child: Text( - failed - ? tr('복사 · 이동 멈춤 - 눌러서 보기') - : running == 0 - ? tr('복사 · 이동 끝') - : percent == null - ? trf('복사 · 이동 {0}개', [running]) - : trf('복사 · 이동 {0}개 · {1}%', [running, percent]), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12.5), - ), - ), - const SizedBox(width: 4), - Tooltip(message: tr('진행 패널 보기'), child: const Icon(Icons.expand_less, size: 18)), - ]), - ), - ), - ); -} - -/// 76: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시) -/// 74: 패턴 넣기 -class _PatternDialog extends StatefulWidget { - const _PatternDialog(); - - @override - State<_PatternDialog> createState() => _PatternDialogState(); -} - -class _PatternDialogState extends State<_PatternDialog> { - final _ctl = TextEditingController(text: '*.srt'); - - @override - void dispose() { - _ctl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => AlertDialog( - title: Text(tr('패턴으로 고르기')), - content: TextField( - controller: _ctl, - autofocus: true, - decoration: InputDecoration(helperText: tr('* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)')), - onSubmitted: (v) => Navigator.pop(context, v), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(context, _ctl.text), child: Text(tr('고르기'))), - ], - ); -} - /// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음). /// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름 class _MenuIconRow extends PopupMenuEntry { diff --git a/test/explorer_buttons_contract_test.dart b/test/explorer_buttons_contract_test.dart new file mode 100644 index 0000000..d30b5cd --- /dev/null +++ b/test/explorer_buttons_contract_test.dart @@ -0,0 +1,222 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/transfer_job.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show samePath; +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/explorer_buttons.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart'; +import 'package:jj_mkvmaker/ui/explorer/marks_bar.dart'; +import 'package:jj_mkvmaker/ui/explorer/toolbar.dart'; +import 'package:jj_mkvmaker/ui/explorer/transfer_panel.dart'; +import 'package:path/path.dart' as p; + +/// E5d-4 약속 시험: explorer_buttons · toolbar · marks_bar · transfer_panel · job_view 맨 위 약속 +/// (실제 시험 폴더 · 실제 화면 · 실제 복사 작업. 화면의 일 [ExplorerCommands] 은 이 시험 화면이 받아 적는다) +void main() { + late Directory tmp; + late String root, show, ep1; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_btn_ui_'); + root = tmp.path; + show = p.join(root, 'Show'); + Directory(show).createSync(); + ep1 = p.join(show, 'ep1.mkv'); + File(ep1).writeAsStringSync('1'); + File(p.join(show, 'ep2.mkv')).writeAsStringSync('2'); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future<(ExplorerButtons, ExplorerController, ExplorerSelect, _HostState)> open(WidgetTester t) async { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey<_HostState>(); + await t.pumpWidget(MaterialApp(home: _Host(key: key))); + final host = key.currentState!; + final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')]; + final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); + addTearDown(() { + sel.dispose(); + ctl.dispose(); + }); + await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); + return (ExplorerButtons(ctl, sel, host), ctl, sel, host); + } + + testWidgets('버튼: 상위 폴더 · 선택 · 새 폴더 (실제로 만들고 그 줄로) · 복사 → 화면의 일 · 길게 = 짝 동작', (t) async { + final (b, ctl, sel, host) = await open(t); + final q = ctl.pane; + await t.runAsync(() => b.press(ExplorerButton.select)); + expect(sel.selecting, isTrue); + host.name = 'New'; + await t.runAsync(() => b.press(ExplorerButton.newFolder)); + final made = p.join(show, 'New'); + expect([Directory(made).existsSync(), samePath(q.focused!, made)], [true, true]); + host.targets = [ep1]; + await t.runAsync(() => b.press(ExplorerButton.copy)); + await t.runAsync(() => b.longPress(ExplorerButton.copy)); + await t.runAsync(() => b.longPress(ExplorerButton.delete)); + await t.runAsync(() => b.longPress(ExplorerButton.refresh)); + expect(host.calls, ['copy $ep1', 'move $ep1', 'delete(영구) $ep1']); + await t.runAsync(() => b.press(ExplorerButton.up)); + expect(samePath(q.current, root), isTrue); + await t.runAsync(() => b.press(ExplorerButton.up)); + expect(samePath(q.current, root), isTrue, reason: '맨 위에서는 그대로'); + expect([ExplorerButtons.longPressLabel(ExplorerButton.copy), ExplorerButtons.longPressLabel(ExplorerButton.up)], ['이동', null]); + }); + + testWidgets('버튼 줄: 누르기 · 못 누르는 버튼 · 넘치면 [더 보기] (고정한 것은 줄에)', (t) async { + final pressed = []; + Future bar(double height) async { + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: height, + child: ExplorerToolbar( + buttons: const [ExplorerButton.copy, ExplorerButton.move, ExplorerButton.paste, ExplorerButton.delete, ExplorerButton.orient], + vertical: true, + onPressed: pressed.add, + enabled: (b) => b != ExplorerButton.paste, + selected: (_) => false, + )))))); + } + + await bar(600); + await t.tap(find.text('복사')); + await t.tap(find.text('붙여넣기')); + expect([pressed, find.text('더 보기').evaluate().isEmpty], [ + [ExplorerButton.copy], + true + ]); + await bar(200); + expect([find.text('더 보기').evaluate().length, find.byIcon(Icons.swap_horiz).evaluate().length], [1, 1]); + }); + + testWidgets('표시 막대: 수 · 모두 선택 · 표시 지우기 · 선택 끝', (t) async { + final (_, ctl, sel, host) = await open(t); + final q = ctl.pane; + sel.setSelecting(true); + await t.runAsync(() => sel.toggleMark(q, sel.shown(q, show).first)); + host.show(() => ExplorerMarksBar(sel: sel, pane: q), sel); + await t.pump(); + expect(find.textContaining('1개 표시함'), findsOneWidget); + await t.tap(find.text('모두 선택')); + await t.pump(); + expect(find.textContaining('2개 표시함'), findsOneWidget); + await t.tap(find.text('표시 지우기')); + await t.pump(); + expect(q.sel.isEmpty, isTrue); + await t.tap(find.text('선택 끝')); + expect(sel.selecting, isFalse); + }); + + testWidgets('진행 패널: 실제 복사 작업 · 끝나면 완료 · 숨기면 작은 표시', (t) async { + final (_, ctl, _, host) = await open(t); + final dest = p.join(root, 'Out'); + Directory(dest).createSync(); + final job = TransferJob(sources: [ep1], dest: dest, move: false); + await t.runAsync(job.run); + expect(File(p.join(dest, 'ep1.mkv')).existsSync(), isTrue); + host.show(() => ExplorerTransferPanel(c: ctl.c, jobs: [job], rsync: false), ctl.c); + await t.pump(); + expect(find.textContaining('완료'), findsOneWidget); + await t.runAsync(() => ctl.c.updateSettings((s) => s.explorerProgressHidden = true)); + await t.pump(); + expect([find.byType(TransferProgressChip).evaluate().length, find.textContaining('완료').evaluate().length], [1, 0]); + }); +} + +class _Host extends StatefulWidget { + const _Host({super.key}); + @override + State<_Host> createState() => _HostState(); +} + +/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (화면의 일은 [calls] 에 적기만) +class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands { + final calls = []; + List targets = const []; + String? name; + Widget Function()? child; + + /// [build] 로 그리고, [changes] 가 바뀔 때마다 다시 그린다 + void show(Widget Function() build, Listenable changes) { + changes.addListener(() { + if (mounted) setState(() {}); + }); + setState(() => child = build); + } + + @override + bool get alive => mounted; + @override + void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + @override + void reveal(ExplorerPane pane, String path) {} + @override + void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); + + @override + bool shiftDelete = false; + @override + List targetsOf(ExplorerPane pane) => targets; + @override + Future askName(String title, String initial, {String? hint, bool selectAll = false}) async => name; + @override + bool blockArchive(List paths, {bool self = false}) => false; + @override + Future transfer(ExplorerPane pane, List sources, {required bool move}) async => + calls.add('${move ? 'move' : 'copy'} ${sources.join(',')}'); + @override + Future deleteItems(ExplorerPane pane, List paths) async { + calls.add('delete${shiftDelete ? '(영구)' : ''} ${paths.join(',')}'); + shiftDelete = false; + } + + Never _no() => throw UnimplementedError('이 시험에서 부르지 않음'); + @override + Future runRsync({required bool right, required bool left}) => _no(); + @override + Future play(List paths, {bool internal = false, bool keepOrder = false}) => _no(); + @override + Future playFolder(String dir) => _no(); + @override + Future addMkv(List paths) => _no(); + @override + Future renameItem(ExplorerPane pane, String path) => _no(); + @override + Future batchRename(ExplorerPane pane, List paths) => _no(); + @override + Future paste(ExplorerPane pane, String dest) => _no(); + @override + Future openSearch(ExplorerPane pane) => _no(); + @override + Future openStorage(ExplorerPane pane, String dir) => _no(); + @override + Future openWifiShare(String dir) => _no(); + @override + Future openTrash() => _no(); + @override + Future sortDialog() => _no(); + @override + Future historyDialog(ExplorerPane pane) => _no(); + @override + Future openBookmarks(ExplorerPane pane) => _no(); + @override + Future toggleOrientation() => _no(); + @override + Future layoutDialog() => _no(); + @override + Future buttonsDialog() => _no(); + + @override + Widget build(BuildContext context) => Scaffold(body: child?.call() ?? const SizedBox()); +}