From 79f2a2151f064e60824b43238f0fb22555bce19b Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:48:49 +0900 Subject: [PATCH] =?UTF-8?q?E5d-6:=20=EA=B6=8C=ED=95=9C=20=C2=B7=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=C2=B7=20=EB=B3=B4=EA=B8=B0=20=EC=B0=BD=20?= =?UTF-8?q?=C2=B7=20=EC=B0=BE=EA=B8=B0=20=C2=B7=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EA=B5=AC=20=C2=B7=20=EB=81=8C=EC=96=B4=20?= =?UTF-8?q?=EB=86=93=EA=B8=B0=20=C2=B7=20=ED=82=A4=20=C2=B7=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=EB=A5=BC=20=EB=AA=A8=EB=93=88=EB=A1=9C=20(explorer=5F?= =?UTF-8?q?page=202,032=20=E2=86=92=20474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - explorer/access_flow.dart: ExplorerAccess (모든 파일 권한 · 시스템 폴더 · 루트 · 다시 연결) - explorer/pane_notices.dart: ExplorerNotices (목록 위 안내: 읽기 전용 · 권한 · 읽지 못함 · 서버 키 · 만들다 만 파일) - explorer/view_dialogs.dart: 정렬 · 즐겨찾기 · 내역 · 창 배치 · 버튼 구성 - explorer/search_flow.dart · rename_flow.dart · tools_flow.dart (저장 공간 · Wi-Fi) · drop_flow.dart - explorer/key_flow.dart: ExplorerInput (단축키 · 글자로 빠른 필터 · 마우스 앞뒤) - explorer/explorer_layout.dart · explorer_body.dart · explorer_bars.dart: 배치 규칙 · 창 배치 · 위 막대들 - explorer/explorer_flows.dart: 흐름 모듈 묶음 (버튼 · 메뉴 · 키 · 끌어 놓기가 같이 씀) - 빠른 보기 · MKV 목록 넣기는 open_flow, 창 틀은 pane_view (ExplorerPaneFrame) - ExplorerCommands 16 → 3 (context · otherDir · dual) - 약속 시험: test/explorer_page_modules_contract_test.dart - 동작은 그대로 (전체 단위 2회 · 점검 통합 2회 · 픽셀 비교) Co-Authored-By: Claude Opus 5.5 --- lib/ui/explorer/access_flow.dart | 165 ++ lib/ui/explorer/drop_flow.dart | 122 ++ lib/ui/explorer/explorer_bars.dart | 195 ++ lib/ui/explorer/explorer_body.dart | 130 ++ lib/ui/explorer/explorer_buttons.dart | 84 +- lib/ui/explorer/explorer_flows.dart | 50 + lib/ui/explorer/explorer_layout.dart | 108 ++ lib/ui/explorer/item_menu.dart | 70 +- lib/ui/explorer/key_flow.dart | 175 ++ lib/ui/explorer/open_flow.dart | 51 +- lib/ui/explorer/pane_notices.dart | 275 +++ lib/ui/explorer/pane_view.dart | 54 + lib/ui/explorer/rename_flow.dart | 86 + lib/ui/explorer/search_flow.dart | 156 ++ lib/ui/explorer/tools_flow.dart | 104 + lib/ui/explorer/view_dialogs.dart | 256 +++ lib/ui/explorer_page.dart | 1709 +---------------- test/explorer_buttons_contract_test.dart | 58 +- test/explorer_flows_contract_test.dart | 43 +- test/explorer_page_modules_contract_test.dart | 211 ++ 20 files changed, 2289 insertions(+), 1813 deletions(-) create mode 100644 lib/ui/explorer/access_flow.dart create mode 100644 lib/ui/explorer/drop_flow.dart create mode 100644 lib/ui/explorer/explorer_bars.dart create mode 100644 lib/ui/explorer/explorer_body.dart create mode 100644 lib/ui/explorer/explorer_flows.dart create mode 100644 lib/ui/explorer/explorer_layout.dart create mode 100644 lib/ui/explorer/key_flow.dart create mode 100644 lib/ui/explorer/pane_notices.dart create mode 100644 lib/ui/explorer/rename_flow.dart create mode 100644 lib/ui/explorer/search_flow.dart create mode 100644 lib/ui/explorer/tools_flow.dart create mode 100644 lib/ui/explorer/view_dialogs.dart create mode 100644 test/explorer_page_modules_contract_test.dart diff --git a/lib/ui/explorer/access_flow.dart b/lib/ui/explorer/access_flow.dart new file mode 100644 index 0000000..a6b71c9 --- /dev/null +++ b/lib/ui/explorer/access_flow.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../core/shizuku.dart'; +import '../../l10n/tr.dart'; +import '../../platform/android/android_storage.dart'; +import '../theme.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; + +/// 약속 (E5d-6): 저장 장치에 닿는 일 - Android "모든 파일에 대한 접근" 권한 ([allFiles] · 듀얼 앱, 앱으로 돌아오면 다시 확인), +/// 시스템 폴더 (Shizuku) · 루트 (su) 탭 (이번 실행에서 처음이면 경고 → 켜져 있고 허락받았을 때만 연다, 읽기 전용), +/// 연결 안 된 드라이브 다시 연결 ([reconnect] - 되면 연다, 아니면 까닭을 알림). 바뀌면 알림 +class ExplorerAccess extends ChangeNotifier { + ExplorerAccess(this.ctl, {required this.probe}); + + final ExplorerController ctl; + + /// 드라이브를 열 수 있는지 (화면을 멈추지 않는 비동기) + final Future Function(String path) probe; + + /// 이번 실행에서 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번) + static bool shizukuWarned = false, rootWarned = false; + + @override + void dispose() { + if (allFiles != null) _life.dispose(); + super.dispose(); + } + + Future probeDrive(String path) => ctl.drives.check(path, probe); + + /// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다 + Future reconnect(ExplorerPane pane, String path) async { + await probeDrive(path); + if (!ctl.host.alive) return; + if (ctl.drives.ok[path] == true) { + pane.cache.remove(path); + await ctl.goTo(pane, path); + } else { + ctl.host.snack(ctl.drives.removable.contains(path) + ? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path]) + : trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path])); + } + } + + // ───────── 권한 (Android) ───────── + + /// "모든 파일에 대한 접근" 권한 (없으면 폴더만 보이고 파일은 안 보인다). null = 아직 모름 + bool? allFiles; + + /// 듀얼 앱 (복제한 앱) 인지: 저장소가 /storage/emulated/0 이 아님 + bool dualApp = false; + + late final AppLifecycleListener _life = AppLifecycleListener(onResume: () { + if (Platform.isAndroid && allFiles == false) unawaited(checkAccess(reload: true)); + }); + + Future checkAccess({bool reload = false}) async { + _life; // 다시 돌아오면 (권한 화면에서 허용하고 오면) 다시 확인 + final ok = await AndroidAccess.hasAllFiles(); + var dual = false; + try { + final root = await AndroidAccess.storageRoot(); + dual = root.contains('/emulated/') && !root.endsWith('/emulated/0'); + } catch (_) {} + if (!ctl.host.alive) return; + final changed = ok != allFiles; + allFiles = ok; + dualApp = dual; + notifyListeners(); + if (reload && changed && ok) { + for (final pane in ctl.panes) { + pane.cache.clear(); + for (final d in pane.expanded.toList()) { + await ctl.load(pane, d, force: true); + } + } + } + } + + /// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정) + Future openRoot(BuildContext context, ExplorerPane pane) async { + if (!rootWarned) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + icon: Icon(Icons.security, color: JjColors.warning), + title: Text(tr('루트 폴더 보기 (읽기 전용)')), + content: Text(tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 ' + '보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))), + ], + ), + ); + if (ok != true || !context.mounted) return; + rootWarned = true; + } + try { + final st = await ShizukuRemote.rootBackend.status(); + if (!st.running) { + ctl.host.snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다')); + return; + } + if (!st.granted && !await ShizukuRemote.rootBackend.request()) { + ctl.host.snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)')); + return; + } + } catch (e) { + ctl.host.snack(trf('루트에 연결하지 못했습니다: {0}', [e])); + return; + } + if (!context.mounted) return; + ctl + ..active = ctl.panes.indexOf(pane) + ..update(); + await ctl.goTo(pane, rootHome); + } + + /// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로 + Future openShizuku(BuildContext context, ExplorerPane pane) async { + if (!shizukuWarned) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + icon: Icon(Icons.admin_panel_settings_outlined, color: JjColors.warning), + title: Text(tr('시스템 폴더 보기 (읽기 전용)')), + content: Text(tr('Shizuku 의 권한으로 Android/data · obb 처럼 막힌 폴더를 봅니다. 다른 앱의 데이터 (대화 · 로그인 정보 등) 가 ' + '있을 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))), + ], + ), + ); + if (ok != true || !context.mounted) return; + shizukuWarned = true; + } + try { + final st = await ShizukuRemote.backend.status(); + if (!st.running) { + ctl.host.snack(tr('Shizuku 가 켜져 있지 않습니다 - Shizuku 앱을 설치하고 (무선 디버깅 · adb 로) 시작한 뒤 다시 누르세요')); + return; + } + if (!st.granted && !await ShizukuRemote.backend.request()) { + ctl.host.snack(tr('Shizuku 허락이 없어 볼 수 없습니다')); + return; + } + } catch (e) { + ctl.host.snack(trf('Shizuku 에 연결하지 못했습니다: {0}', [e])); + return; + } + if (!context.mounted) return; + ctl + ..active = ctl.panes.indexOf(pane) + ..update(); + await ctl.goTo(pane, shizukuHome(ctl.local.first.$1)); + } +} diff --git a/lib/ui/explorer/drop_flow.dart b/lib/ui/explorer/drop_flow.dart new file mode 100644 index 0000000..bd98213 --- /dev/null +++ b/lib/ui/explorer/drop_flow.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../app/transfer_job.dart'; +import '../../core/file_ops.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../explorer_drag.dart'; +import '../notice.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'explorer_select.dart'; +import 'transfer_flow.dart'; +import 'write_guard.dart'; + +/// 약속 (E5d-6): 끌어 놓기 (82). 끌 항목은 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만). 놓으면 설정 "끌어 놓기 기본" +/// (묻기 · 복사 · 이동) 뒤 탐색기의 복사 · 이동 ([ExplorerTransfers.run]). 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로. +/// 이동이 끝나면 알림의 [되돌리기] - 새로 생긴 것은 통째로, 합친 폴더는 앱이 옮긴 파일만 (덮어쓴 것 · 모르는 것은 되돌리지 않고 알림) +class ExplorerDrops { + ExplorerDrops(this.ctl, this.sel, this.guard, this.transfers); + + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerGuard guard; + final ExplorerTransfers transfers; + + /// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나 + List dragPaths(ExplorerPane pane, FileEntry e) => + sel.isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path]; + + /// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널). + /// 이동이 끝나면 아래 알림에 [되돌리기] (10초) + Future drop(BuildContext context, DragLoad load, int to, String dest) async { + if (!canDropInto(load, to, dest) || !context.mounted) return; + // X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로 + if (guard.insideArchive(dest) || ctl.arc.contains(dest)) { + ctl.host.snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); + return; + } + if (load.paths.any(guard.insideArchive)) { + await guard.extractFrom(context, load.paths, dest); + return; + } + ctl + ..active = to + ..update(); + final mode = ctl.c.settings.explorerDropAction; + final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move'; + if (move == null || !context.mounted) return; + // 되돌리기에 쓸 것: 대상에 이미 있던 이름 (합친 폴더는 통째로 되돌리지 않게) + final existed = {}; + if (move) { + for (final s in load.paths) { + final t = vJoin(dest, vBasename(s)); + if (await vExists(t)) existed.add(t); + } + } + if (!context.mounted) return; + await transfers.run(context, ctl.panes[load.from], load.paths, dest, + move: move, onFinished: move ? (job) => _offerUndoMove(context, load, job, existed) : null); + } + + /// 82: 이동 되돌리기 알림. 실제로 옮긴 것만 원래 자리로: 새로 생긴 항목은 통째로, 이미 있던 폴더에 합친 것은 앱이 옮긴 파일만 + /// (robocopy 로 합친 폴더는 무엇을 옮겼는지 몰라 되돌리지 않고 알린다) + void _offerUndoMove(BuildContext context, DragLoad load, TransferJob job, Set existed) { + // (원래 폴더 → 지금 경로들) + final back = >{}; + // 되돌리지 않는 것: 합친 폴더 (robocopy - 무엇을 옮겼는지 모름) · 같은 이름을 덮어쓴 파일 (원래 파일은 이미 없음, 15 지적) + var notUndoable = 0, overwritten = 0; + final pairs = job.made.length == load.paths.length + ? [for (var i = 0; i < load.paths.length; i++) (load.paths[i], job.made[i])] + : [ + for (final m in job.made) + for (final s in load.paths) + if (vBasename(s) == vBasename(m)) (s, m), + ]; + for (final (src, now) in pairs) { + if (!existed.contains(now)) { + back.putIfAbsent(vDirname(src), () => []).add(now); + continue; + } + // 같은 이름을 덮어쓴 파일: 되돌리면 대상에 아무것도 남지 않으므로 그대로 둔다 + if (!vIsDirSync(now)) { + overwritten++; + continue; + } + // 합친 폴더: 그 안에서 실제로 옮긴 파일만 + final files = [for (final f in job.movedFiles) if (isSameOrInside(f, src) && !samePath(f, src)) f]; + if (files.isEmpty) { + notUndoable++; + continue; + } + for (final f in files) { + final rest = f.substring(src.length + 1); + back.putIfAbsent(vDirname(f), () => []).add(vJoin(now, rest)); + } + } + if (back.isEmpty || !context.mounted) return; + final moved = back.values.fold(0, (a, l) => a + l.length); + showNotice(ScaffoldMessenger.of(context)..hideCurrentSnackBar(), + text: joinSentences([ + trf('{0}개를 옮겼습니다.', [moved]), + if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), + if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]), + ]), + action: (tr('되돌리기'), () => unawaited(_undoMove(context, ctl.panes[load.from], back)))); + } + + /// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창) + Future _undoMove(BuildContext context, ExplorerPane pane, Map> back) async { + for (final MapEntry(key: dir, value: items) in back.entries) { + final there = [for (final x in items) if (await vExists(x)) x]; + if (there.isEmpty || !context.mounted) continue; + try { + if (!await vExists(dir)) await vMkdirs(dir); + } catch (_) {} + if (!context.mounted) return; + await transfers.run(context, pane, there, dir, move: true); + } + } +} diff --git a/lib/ui/explorer/explorer_bars.dart b/lib/ui/explorer/explorer_bars.dart new file mode 100644 index 0000000..029d925 --- /dev/null +++ b/lib/ui/explorer/explorer_bars.dart @@ -0,0 +1,195 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../core/file_ops.dart' show samePath; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../app_actions.dart'; +import '../folder_picker.dart' show pickFolderOrDav; +import '../path_label.dart'; +import '../tail_text.dart'; +import '../theme.dart'; +import 'explorer_buttons.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; + +// 약속 (E5d-6): 탐색기 위의 막대들 - 화면 위 막대 ([ExplorerTopBar]: 제목 · 경로 막대 (누르면 위 폴더들) · ⋮ 배치 메뉴), +// 한 창일 때 복사 · 이동 갈 곳 줄 ([CopyTargetBar]), 좁은 Rsync 화면의 [왼쪽 | 오른쪽] ([RsyncPaneSwitch]). 누르는 일은 버튼 · 창으로 + +/// 화면 위 막대. X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 +class ExplorerTopBar extends StatelessWidget { + const ExplorerTopBar({super.key, required this.ctl, required this.buttons}); + + final ExplorerController ctl; + final ExplorerButtons buttons; + + /// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터) + static List ancestorsOf(ExplorerPane pane) { + final out = []; + var d = pane.current; + for (var i = 0; i < 64; i++) { + out.add(d); + if (samePath(d, pane.root)) break; + final up = vDirname(d); + if (up == d || up.isEmpty) break; + d = up; + } + return out; + } + + @override + Widget build(BuildContext context) => AppTopBar( + nav: AppNavButtons(onExplorerPage: !ctl.rsync, onRsyncPage: ctl.rsync), + actions: AppActions(c: ctl.c), + // 아주 좁고 글자가 크면 (300 폭 · 1.3 × 150%) 제목도 줄임 - 넘쳐 그리기 오류가 나던 것 (날짜 열 312 조합 시험에서 찾음). + // 보통은 제목 폭 그대로 (경로 막대에 80 남짓 남을 때까지) + middle: LayoutBuilder(builder: (context, box) => Row(children: [ + ConstrainedBox( + constraints: BoxConstraints(maxWidth: math.max(40, box.maxWidth - 92)), + child: Text(ctl.rsync ? 'Rsync' : tr('파일 탐색기'), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 12), + // X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 + Expanded( + child: !ctl.ready + ? const SizedBox.shrink() + : PopupMenuButton( + tooltip: tr('위 폴더로 가기'), + position: PopupMenuPosition.under, + onSelected: (d) => ctl.goTo(ctl.pane, d), + itemBuilder: (_) => [ + for (final d in ancestorsOf(ctl.pane)) + PopupMenuItem( + value: d, + enabled: !samePath(d, ctl.pane.current), + child: Text(ctl.displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + // 관리자 지시 (10/21, 28 지적): 좁으면 앞을 "…" 로 - 지금 폴더 (끝) 가 늘 보이게 + child: TailText(ctl.displayPath(ctl.pane.current), style: TextStyle(color: JjColors.textDim)), + ), + ), + PopupMenuButton( + tooltip: tr('창 배치 · 버튼 구성'), + icon: const Icon(Icons.more_vert), + onSelected: (v) => v is ExplorerButton ? buttons.press(v) : ctl.toggleAppHidden(), + itemBuilder: (_) => [ + for (final b in [ + if (!ctl.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons], + ExplorerButton.sort, + ExplorerButton.hidden, + if (!ctl.rsync) ExplorerButton.storage, + if (!ctl.rsync) ExplorerButton.wifiShare, + ]) + PopupMenuItem( + value: b, + child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]), + ), + // X-plore 숨기기: 큰 폴더를 펼쳐 목록 맨 아래가 멀 때도 (관리자 결정 10/18) + if (!ctl.rsync && ctl.c.settings.explorerHiddenItems.isNotEmpty) + PopupMenuItem( + value: 'appHidden', + child: Row(children: [ + Icon(ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), + const SizedBox(width: 12), + Flexible( + child: Text(ctl.showAppHidden + ? tr('목록에서 숨긴 항목 다시 숨기기') + : trf('목록에서 숨긴 항목 보이기 ({0}개)', [ctl.c.settings.explorerHiddenItems.length])), + ), + ]), + ), + ], + ), + ])), + ); +} + +/// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더] +class CopyTargetBar extends StatelessWidget { + const CopyTargetBar({super.key, required this.ctl}); + + final ExplorerController ctl; + + /// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말) + String _folderName(String dir) => + ctl.volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? ctl.displayPath(dir) : vBasename(dir))).$2; + + @override + Widget build(BuildContext context) { + final t = ctl.panes[1 - ctl.active]; + return Material( + color: JjColors.accent.withValues(alpha: 0.10), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Row(children: [ + Icon(Icons.arrow_forward, size: 18, color: JjColors.accent), + const SizedBox(width: 6), + // 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려) + Expanded( + child: Tooltip( + message: ctl.displayPath(t.current), + triggerMode: TooltipTriggerMode.longPress, + child: LayoutBuilder(builder: (context, box) { + const style = TextStyle(fontSize: 12.5); + final label = trf('갈 곳: {0}', ['']); + final room = box.maxWidth - textWidth(context, label, style); + return Text('$label${middleEllipsis(context, _folderName(t.current), style, room)}', maxLines: 1, overflow: TextOverflow.clip, style: style); + }), + ), + ), + TextButton( + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + onPressed: () => ctl.goTo(t, ctl.pane.current), + child: Text(tr('지금 폴더로')), + ), + TextButton( + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + onPressed: () async { + final d = await pickFolderOrDav(context, tr('복사 · 이동 갈 곳'), t.current); + if (d != null && context.mounted) await ctl.goTo(t, d); + }, + child: Text(tr('다른 폴더')), + ), + ]), + ), + ); + } +} + +/// 191: 좁은 Rsync 화면의 [왼쪽 | 오른쪽] - 그 창에서 고른 폴더 (없으면 지금 폴더) 이름과 함께 +class RsyncPaneSwitch extends StatelessWidget { + const RsyncPaneSwitch({super.key, required this.ctl}); + + final ExplorerController ctl; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 0), + child: Row(children: [ + for (final (k, side) in [(0, tr('왼쪽')), (1, tr('오른쪽'))]) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: ChoiceChip( + showCheckmark: false, + selected: ctl.active == k, + onSelected: (_) => ctl + ..active = k + ..update(), + label: SizedBox( + width: double.infinity, + child: Text( + '$side · ${vBasename(!ctl.panes[k].sel.isEmpty ? ctl.panes[k].sel.marked.first : ctl.panes[k].current)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), + ]), + ); +} diff --git a/lib/ui/explorer/explorer_body.dart b/lib/ui/explorer/explorer_body.dart new file mode 100644 index 0000000..bd1c2be --- /dev/null +++ b/lib/ui/explorer/explorer_body.dart @@ -0,0 +1,130 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'explorer_bars.dart'; +import 'explorer_buttons.dart'; +import 'explorer_controller.dart'; +import 'explorer_flows.dart'; +import 'explorer_layout.dart'; +import 'explorer_select.dart'; +import 'pane_view.dart'; +import 'toolbar.dart'; + +/// 약속 (E5d-6): 창들과 기능 버튼 줄의 배치 - 설정 (두 창 · 폴더 + 파일 목록 · 한 창, 좌우 · 위아래, 버튼 줄 사이 · 끝 · 숨김) 과 +/// 폭 (좁으면 잠깐 한 창 · 좁은 트리 · 좁은 Rsync 는 창 하나씩) 을 [ExplorerLayout] 에 적고 그린다. 창은 배치가 바뀌어도 같은 위젯 +/// (스크롤이 맨 위로 가지 않게). 모양이 바뀌면 보던 폴더가 다시 보이게 한다 +class ExplorerPanesBody extends StatelessWidget { + const ExplorerPanesBody( + {super.key, required this.ctl, required this.sel, required this.layout, required this.parts, required this.buttons, required this.flows}); + + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerLayout layout; + final ExplorerPaneParts parts; + final ExplorerButtons buttons; + final ExplorerFlows flows; + + @override + Widget build(BuildContext context) { + final s = ctl.c.settings; + final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}'; + if (ctl.ready && shape != layout.shape) { + if (layout.shape.isNotEmpty) { + for (final pane in ctl.panes) { + layout.reveal(pane, pane.current); + } + } + layout.shape = shape; + } + final dual = layout.dual; + return LayoutBuilder(builder: (context, box) { + // 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼. + // 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다 + final wantSplit = !ctl.rsync && s.explorerLayout == 'split'; + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14 * layout.listTextScale; + final barW = s.explorerToolbar == 'hidden' ? 0.0 : 72.0; + final sideNow = switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight }; + final tightWas = layout.splitTight; + layout.splitTight = wantSplit && sideNow && box.maxWidth - barW < 182 + 90 * textScale; + // 바뀌면 이 builder 밖 (버튼 이름 "두 창" 등) 도 한 번 다시 그린다 + if (tightWas != layout.splitTight) { + ctl.host.afterFrame(() { + if (ctl.host.alive) ctl.update(); + }); + } + final split = layout.split; + // 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게 + if (ctl.ready) layout.revealNewViews(); + final side = !(dual || split) || + switch (s.explorerOrientation) { + 'side' => true, + 'stacked' => false, + _ => box.maxWidth >= box.maxHeight, + }; + // 191: Rsync 화면이 좁으면 (폴드 접은 화면 · 폰 세로의 좌우 두 창) 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 본다 + // (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다) + final rsyncOne = ctl.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth; + layout.narrowSplit = split && side && box.maxWidth < ExplorerLayout.narrowSplitWidth; + final bar = s.explorerToolbar == 'hidden' && !ctl.rsync + ? null + : ExplorerToolbar( + // X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음) + onLongPress: ctl.rsync ? null : buttons.longPress, + // Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에 + buttons: ctl.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons), + vertical: side && !rsyncOne, + panesSide: side, + onPressed: buttons.press, + enabled: (b) => switch (b) { + ExplorerButton.paste => flows.transfers.clip.isNotEmpty, + _ => true, + }, + selected: (b) => + (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && sel.selecting), + twoPanes: dual || split, + pinned: {ExplorerButton.orient, if (flows.transfers.clip.isNotEmpty) ExplorerButton.paste}, + ); + final middle = bar != null && (ctl.rsync || s.explorerToolbar == 'middle'); + final edge = bar != null && !ctl.rsync && s.explorerToolbar == 'edge'; + // 180: 창마다 같은 열쇠 - 가로 ↔ 세로 배치가 바뀌어도 (창 크기 · 작업 현황이 열림) 창을 새로 만들지 않는다 (스크롤이 맨 위로 가지 않게) + Widget keep(int k, Widget w) => KeyedSubtree(key: layout.viewKeys[k], child: w); + if (rsyncOne) { + final i = ctl.active.clamp(0, 1); + return Column(children: [ + RsyncPaneSwitch(ctl: ctl), + ?bar, + Expanded(child: keep(i, ExplorerPaneView(ctl: ctl, sel: sel, parts: parts, index: i, foldersOnly: true))), + ]); + } + final children = [ + if (dual) ...[ + Expanded(child: keep(0, ExplorerPaneView(ctl: ctl, sel: sel, parts: parts, index: 0, foldersOnly: ctl.rsync))), + if (middle) bar, + Expanded(child: keep(1, ExplorerPaneView(ctl: ctl, sel: sel, parts: parts, index: 1, foldersOnly: ctl.rsync))), + if (edge) bar, + ] else if (split) ...[ + // 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) - + // 버튼 줄까지 넣어 38% 로 하면 360 폭에서 오른쪽 목록의 파일 이름이 두세 글자만 남았다 + if (layout.narrowSplit) + SizedBox( + width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42), + child: keep(0, ExplorerPaneView(ctl: ctl, sel: sel, parts: parts, index: 0, foldersOnly: true))) + else + Expanded(flex: 2, child: keep(0, ExplorerPaneView(ctl: ctl, sel: sel, parts: parts, index: 0, foldersOnly: true))), + if (middle) bar, + Expanded(flex: 3, child: keep(2, ExplorerListView(ctl: ctl, sel: sel, parts: parts, pane: ctl.panes[0]))), + if (edge) bar, + ] else ...[ + if (middle) bar, + Expanded( + child: layout.hiddenTarget + ? Column(children: [ListenableBuilder(listenable: ctl.panes[1], builder: (_, _) => CopyTargetBar(ctl: ctl)), Expanded(child: keep(0, ExplorerPaneView(ctl: ctl, sel: sel, parts: parts, index: 0)))]) + : keep(0, ExplorerPaneView(ctl: ctl, sel: sel, parts: parts, index: 0))), + if (edge) bar, + ], + ]; + return side ? Row(children: children) : Column(children: children); + }); + } +} diff --git a/lib/ui/explorer/explorer_buttons.dart b/lib/ui/explorer/explorer_buttons.dart index b2f7c82..0ca9c8c 100644 --- a/lib/ui/explorer/explorer_buttons.dart +++ b/lib/ui/explorer/explorer_buttons.dart @@ -10,14 +10,9 @@ 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 'delete_flow.dart'; +import 'explorer_flows.dart'; import 'explorer_select.dart'; import 'name_dialog.dart'; -import 'open_flow.dart'; -import 'rsync_flow.dart'; -import 'transfer_flow.dart'; -import 'write_guard.dart'; /// 기능 버튼 (가운데 줄). 이름은 설정 (explorerButtons) 에 저장된다. enum ExplorerButton { @@ -93,7 +88,7 @@ enum ExplorerButton { } } -/// 약속 (E5d-4): 버튼이 부르는 화면의 일 (지우기 · 옮기기 · 이름 바꾸기 · 창 · 재생 등). 흐름 모듈로 옮겨 갈 때마다 이 목록이 줄어든다 +/// 약속 (E5d-4 · E5d-6): 버튼 · 메뉴가 화면에서 받는 것 - 창 · 메뉴를 띄울 곳 · 배치 (다른 창 · 두 창) · 빠른 보기. 일은 [ExplorerFlows] abstract interface class ExplorerCommands { /// 창 · 메뉴를 띄울 곳 BuildContext get context; @@ -103,38 +98,17 @@ abstract interface class ExplorerCommands { /// 두 창으로 보이는지 bool get dual; - - /// 81: 빠른 보기 - [path] (없으면 지금 줄). 같은 폴더에 보이는 항목을 ← → 로 - Future quickLook(ExplorerPane pane, [String? path]); - Future addMkv(List paths); - - Future renameItem(ExplorerPane pane, String path); - Future batchRename(ExplorerPane pane, List paths); - Future openSearch(ExplorerPane pane); - Future openStorage(ExplorerPane pane, String dir); - Future openWifiShare(String dir); - Future sortDialog(); - Future historyDialog(ExplorerPane pane); - Future openBookmarks(ExplorerPane pane); - Future toggleOrientation(); - Future layoutDialog(); - Future buttonsDialog(); } /// 약속 (E5d-4): 기능 버튼 하나 → 할 일 (가운데 줄 · 단축키 · 위 메뉴가 같이 부른다). 지금 창 ([ExplorerController.pane]) 에 한다. -/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 복사 · 이동 · 지우기 · rsync 는 흐름 모듈, 나머지는 [ExplorerCommands]. 못 하면 까닭을 알림 (Host) +/// 창 · 고르기 · 다시 읽기 · 새 폴더 · 숨은 항목은 여기서, 나머지는 흐름 모듈 ([ExplorerFlows]). 못 하면 까닭을 알림 (Host) class ExplorerButtons { - ExplorerButtons(this.ctl, this.sel, this.cmd, - {required this.guard, required this.transfers, required this.deletes, required this.rsync, required this.opener}); + ExplorerButtons(this.ctl, this.sel, this.cmd, this.flows); final ExplorerController ctl; final ExplorerSelect sel; final ExplorerCommands cmd; - final ExplorerGuard guard; - final ExplorerTransfers transfers; - final ExplorerDeleteFlow deletes; - final ExplorerRsyncFlow rsync; - final ExplorerOpener opener; + final ExplorerFlows flows; /// X-plore b: 길게 누르면 하는 짝 동작의 이름 (없으면 null) static String? longPressLabel(ExplorerButton b) => switch (b) { @@ -152,7 +126,7 @@ class ExplorerButtons { case ExplorerButton.move: return press(ExplorerButton.copy); case ExplorerButton.delete: - deletes.shiftDelete = true; + flows.deletes.shiftDelete = true; return press(ExplorerButton.delete); default: return; @@ -172,11 +146,11 @@ class ExplorerButtons { case ExplorerButton.monitor: await MonitorPage.open(cmd.context, ctl.c); case ExplorerButton.toRight: - await rsync.run(cmd.context, right: true, left: false); + await flows.rsync.run(cmd.context, right: true, left: false); case ExplorerButton.toLeft: - await rsync.run(cmd.context, right: false, left: true); + await flows.rsync.run(cmd.context, right: false, left: true); case ExplorerButton.both: - await rsync.run(cmd.context, right: true, left: true); + await flows.rsync.run(cmd.context, right: true, left: true); case ExplorerButton.refresh: // 183: 두 창 모두 (밖에서 지운 파일이 다른 창에 남지 않게) await ctl.reloadOpen(); @@ -185,16 +159,16 @@ class ExplorerButtons { // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList(); if (videos.isNotEmpty) { - await opener.play(cmd.context, videos, keepOrder: !pane.sel.isEmpty); + await flows.opener.play(cmd.context, videos, keepOrder: !pane.sel.isEmpty); } else { - await opener.playFolder(cmd.context, t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); + await flows.opener.playFolder(cmd.context, t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current); } case ExplorerButton.addMkv: final t = sel.targets(pane); - await cmd.addMkv(t.isEmpty ? [pane.current] : t); + await flows.opener.addMkv(t.isEmpty ? [pane.current] : t); case ExplorerButton.newFolder: // X-plore 비교 f: 펼친 압축 안 (지금 폴더가 압축 안의 폴더) 에는 만들지 않는다 - if (guard.blockArchive([pane.current], self: true)) return; + if (flows.guard.blockArchive([pane.current], self: true)) return; final name = await askName(cmd.context, tr('새 폴더'), ''); if (name == null) return; try { @@ -222,22 +196,22 @@ class ExplorerButtons { return; } // 78: 여럿을 골랐으면 일괄 이름 바꾸기 - if (t.length > 1) return cmd.batchRename(pane, t); - await cmd.renameItem(pane, t.first); + if (t.length > 1) return flows.renames.batch(cmd.context, pane, t); + await flows.renames.rename(cmd.context, pane, t.first); case ExplorerButton.copy: - await transfers.transfer(cmd.context, pane, sel.targets(pane), move: false, otherDir: cmd.otherDir); + await flows.transfers.transfer(cmd.context, pane, sel.targets(pane), move: false, otherDir: cmd.otherDir); case ExplorerButton.move: - await transfers.transfer(cmd.context, pane, sel.targets(pane), move: true, otherDir: cmd.otherDir); + await flows.transfers.transfer(cmd.context, pane, sel.targets(pane), move: true, otherDir: cmd.otherDir); case ExplorerButton.paste: - await transfers.paste(cmd.context, pane, pane.current); + await flows.transfers.paste(cmd.context, pane, pane.current); case ExplorerButton.delete: - await deletes.run(cmd.context, pane, sel.targets(pane)); + await flows.deletes.run(cmd.context, pane, sel.targets(pane)); case ExplorerButton.search: - await cmd.openSearch(pane); + await flows.search.open(cmd.context, pane); case ExplorerButton.storage: - await cmd.openStorage(pane, pane.current); + await flows.tools.openStorage(cmd.context, pane, pane.current); case ExplorerButton.wifiShare: - await cmd.openWifiShare(pane.current); + await flows.tools.openWifiShare(cmd.context, pane.current); case ExplorerButton.apps: await openAppManager(cmd.context); case ExplorerButton.vault: @@ -247,9 +221,9 @@ class ExplorerButtons { await openVault(cmd.context, ctl.c); } case ExplorerButton.trash: - await deletes.openTrash(cmd.context); + await flows.deletes.openTrash(cmd.context); case ExplorerButton.sort: - await cmd.sortDialog(); + await flows.dialogs.sortDialog(cmd.context); case ExplorerButton.hidden: await ctl.c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden); for (final x in ctl.panes) { @@ -260,15 +234,15 @@ class ExplorerButtons { } ctl.host.snack(ctl.c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.')); case ExplorerButton.history: - await cmd.historyDialog(pane); + await flows.dialogs.historyDialog(cmd.context, pane); case ExplorerButton.bookmarks: - await cmd.openBookmarks(pane); + await flows.dialogs.openBookmarks(cmd.context, pane); case ExplorerButton.orient: - await cmd.toggleOrientation(); + await flows.dialogs.toggleOrientation(cmd.context); case ExplorerButton.layout: - await cmd.layoutDialog(); + await flows.dialogs.layoutDialog(cmd.context); case ExplorerButton.buttons: - await cmd.buttonsDialog(); + await flows.dialogs.buttonsDialog(cmd.context); } } } diff --git a/lib/ui/explorer/explorer_flows.dart b/lib/ui/explorer/explorer_flows.dart new file mode 100644 index 0000000..5950a28 --- /dev/null +++ b/lib/ui/explorer/explorer_flows.dart @@ -0,0 +1,50 @@ +import 'delete_flow.dart'; +import 'drop_flow.dart'; +import 'explorer_controller.dart'; +import 'explorer_jobs.dart'; +import 'explorer_pane.dart'; +import 'explorer_select.dart'; +import 'open_flow.dart'; +import 'rename_flow.dart'; +import 'rsync_flow.dart'; +import 'search_flow.dart'; +import 'tools_flow.dart'; +import 'transfer_flow.dart'; +import 'view_dialogs.dart'; +import 'write_guard.dart'; + +/// 약속 (E5d-6): 탐색기 하나의 흐름 모듈 묶음 - 버튼 · 줄 메뉴 · 단축키 · 끌어 놓기가 같은 것을 쓴다 (작업 목록 · 담아 둔 것이 하나). +/// 처음 쓸 때 만든다. 화면 배치에서 오는 것 (다른 창의 폴더 · 창이 둘인지 · 앱 휴지통을 쓰는 기기인지) 은 함수로 받는다 +class ExplorerFlows { + ExplorerFlows(this.ctl, this.sel, + {required this.androidTrash, required this.otherDirOf, required this.otherDir, required this.twoPanes}); + + final ExplorerController ctl; + final ExplorerSelect sel; + final bool Function() androidTrash; + + /// [pane] 옆 창의 지금 폴더 (두 창일 때만, 압축 풀 곳) + final String? Function(ExplorerPane pane) otherDirOf; + + /// 복사 · 이동 갈 곳 (두 창 · 숨은 다른 창, 없으면 담아 두기) + final String? Function() otherDir; + final bool Function() twoPanes; + + late final guard = ExplorerGuard(ctl); + late final jobs = ExplorerJobs(ctl); + late final transfers = ExplorerTransfers(ctl, guard, jobs); + late final rsync = ExplorerRsyncFlow(ctl, jobs, transfers); + late final deletes = ExplorerDeleteFlow(ctl, sel, guard, androidTrash: androidTrash); + late final opener = ExplorerOpener(ctl, sel, guard, otherDirOf: otherDirOf); + late final renames = ExplorerRenames(ctl, guard); + late final tools = ExplorerTools(ctl, deletes, transfers); + late final dialogs = ExplorerViewDialogs(ctl, twoPanes: twoPanes); + late final search = ExplorerSearch(ctl, opener, transfers, deletes, otherDir: otherDir); + late final drops = ExplorerDrops(ctl, sel, guard, transfers); + + void dispose() { + search.dispose(); + transfers.dispose(); + jobs.dispose(); + } +} diff --git a/lib/ui/explorer/explorer_layout.dart b/lib/ui/explorer/explorer_layout.dart new file mode 100644 index 0000000..8d7cff7 --- /dev/null +++ b/lib/ui/explorer/explorer_layout.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '../../core/file_ops.dart' show samePath; +import '../../l10n/tr.dart'; +import '../explorer_look.dart'; +import '../app_actions.dart' show isCompact; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; + +/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩) +const double rsyncTwoPaneMinWidth = 560; + +/// 약속 (E5d-6): 창 배치 규칙 - 두 창 · 폴더 + 파일 목록 · 한 창 (설정, "자동" 은 화면 크기 따라), 좁아서 잠깐 한 창 ([splitTight]) · +/// 좁은 "폴더 + 파일 목록" ([narrowSplit]) 은 [ExplorerPanesBody] 가 폭을 보고 정한다. 줄 높이 (목록 글자 크기 · 줄 간격), +/// 복사 · 이동 갈 곳 창 ([other]), 밀어서 창 바꾸기, 항목이 보이게 스크롤 ([reveal]). [context]: 화면 (글자 크기 · 폭) +class ExplorerLayout { + ExplorerLayout(this.ctl, {required this.context}); + + final ExplorerController ctl; + final BuildContext Function() context; + + /// 180: 왼쪽 창 · 오른쪽 창 · 파일 목록 (배치가 바뀌어도 같은 위젯) + final viewKeys = [GlobalKey(), GlobalKey(), GlobalKey()]; + + /// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로 + String shape = ''; + + /// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로 + /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) + void reveal(ExplorerPane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path)); + + void _revealNow(ExplorerPane pane, String path) { + if (!ctl.host.alive || !pane.scroll.hasClients) return; + // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) + final i = ctl.rows(pane, foldersOnly: split || ctl.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path)); + if (i < 0) return; + final pos = pane.scroll.position; + // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. + // 넘치면 다음 배치 때 실제 끝으로 맞춰진다 + pane.scroll.jumpTo((i * rowHeight() - pos.viewportDimension / 4).clamp(0.0, double.infinity)); + } + + /// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때) + /// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [ctl.goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다 + void revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) { + if (!ctl.host.alive) return; + for (final pane in ctl.panes) { + final on = pane.scroll.hasClients; + if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current); + pane.onScreen = on; + } + }); + + /// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이 + double get listTextScale => ctl.c.settings.explorerTextScale / 100; + double rowHeight() => ctl.look.rowHeightOf(context(), textScale: listTextScale, spacing: rowSpacingFactor(ctl.c.settings.explorerRowSpacing)); + + /// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치 + bool get split => !ctl.rsync && ctl.c.settings.explorerLayout == 'split' && !splitTight; + + /// 관리자 지시 10/20 (28 검토): 글자가 커서 (기기 글자 × 목록 글자 크기) 좁은 화면에 트리 · 목록 두 창이 다 들어가지 않으면 + /// 그동안만 한 창 (트리 + 위 경로 막대) 으로 보인다 - 고른 배치 (설정) 는 그대로, 화면이 넓어지거나 글자가 작아지면 다시 두 창. [ExplorerPanesBody] 가 정한다 + bool splitTight = false; + + /// 관리자 지시 10/20: 좁은 화면 (폭 < [narrowSplitWidth]) 의 "폴더 + 파일 목록" 좌우 배치 - 트리 창에 최소 폭을 두고 + /// ✔ 칸 · 크기 줄을 숨긴다 (412 폭에서 트리가 77px 이라 폴더 이름이 한 글자도 안 보였다). [ExplorerPanesBody] 가 정한다 + bool narrowSplit = false; + static const narrowSplitWidth = 600.0; + + /// 좌우 두 창 (Rsync 화면은 늘) + bool get dual => + ctl.rsync || + ctl.c.settings.explorerLayout == 'dual' || + // 자동: 넓으면 두 창, 좁은 화면 (폰 세로) 은 한 창 - 같은 목록이 반씩 잘려 두 번 보이지 않게 + (ctl.c.settings.explorerLayout == 'auto' && !isCompact(context())); + + + /// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽) + /// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정) + /// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정) + bool get alwaysMarks => !ctl.rsync && ctl.look == ExplorerStyle.xplore && ctl.c.settings.explorerAlwaysMarks; + + /// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정) + bool get swipePanes => !ctl.rsync && ctl.look == ExplorerStyle.xplore && ctl.c.settings.explorerSwipePanes; + + /// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다 + bool switchPane(int dir) { + if (!swipePanes) return false; + if (dual || split) { + ctl.active = 1 - ctl.active; + } else { + final t = ctl.panes[0]; + ctl.panes[0] = ctl.panes[1]; + ctl.panes[1] = t; + ctl.active = 0; + } + ctl.update(); + ctl.remember(ctl.pane, ctl.pane.current); + ctl.host.snack(trf('창 바꿈: {0}', [ctl.displayPath(ctl.pane.current)])); + return true; + } + + /// 복사 · 이동 갈 곳 창 (두 창 · 숨은 다른 창, 없으면 null - 담아 두기) + ExplorerPane? get other => dual || hiddenTarget ? ctl.panes[1 - ctl.active] : null; + + /// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄) + bool get hiddenTarget => !ctl.rsync && !dual && !split && ctl.c.settings.explorerOnePaneCopy == 'other'; +} diff --git a/lib/ui/explorer/item_menu.dart b/lib/ui/explorer/item_menu.dart index 8f1e778..56875b1 100644 --- a/lib/ui/explorer/item_menu.dart +++ b/lib/ui/explorer/item_menu.dart @@ -18,30 +18,24 @@ import '../notice.dart'; import '../sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; import '../theme.dart'; import '../vault_page.dart' show openVault; -import 'delete_flow.dart'; import 'explorer_buttons.dart'; import 'explorer_controller.dart'; +import 'explorer_flows.dart'; import 'explorer_pane.dart'; import 'explorer_select.dart'; +import 'open_flow.dart' show ExplorerOpener; import 'item_actions.dart'; -import 'open_flow.dart'; -import 'transfer_flow.dart'; -import 'write_guard.dart'; /// 약속 (E5d-5): 줄을 길게 누르거나 오른쪽 클릭하면 뜨는 메뉴. 누른 것이 표시한 것 중 하나면 표시한 것 모두에 한다 (77). /// 위 아이콘 줄 (복사 · 이동 · 이름 변경 · 삭제 · 공유) + 상황별 항목 (Rsync 화면은 폴더에 맞는 것만, 압축 안은 새 파일을 만드는 것은 숨김). /// 고른 것은 흐름 모듈 (열기 · 복사 · 이동 · 지우기) 이나 [ExplorerCommands] 로 한다. 고르지 않으면 아무것도 안 함 class ExplorerItemMenu { - ExplorerItemMenu(this.ctl, this.sel, this.cmd, - {required this.guard, required this.transfers, required this.deletes, required this.opener, required this.nomedia}); + ExplorerItemMenu(this.ctl, this.sel, this.cmd, this.flows, {required this.nomedia}); final ExplorerController ctl; final ExplorerSelect sel; final ExplorerCommands cmd; - final ExplorerGuard guard; - final ExplorerTransfers transfers; - final ExplorerDeleteFlow deletes; - final ExplorerOpener opener; + final ExplorerFlows flows; /// .nomedia 항목을 보일 기기인지 (Android) final bool Function() nomedia; @@ -67,7 +61,7 @@ class ExplorerItemMenu { final targets = sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]; final many = targets.length > 1; // 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다 - final inArchive = targets.any(guard.insideArchive) || guard.insideArchive(e.path); + final inArchive = targets.any(flows.guard.insideArchive) || flows.guard.insideArchive(e.path); // 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보) final items = ctl.rsync ? <(String, IconData, String)>[ @@ -133,8 +127,8 @@ class ExplorerItemMenu { // 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기 if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')), if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')), - if (transfers.clip.isNotEmpty) - ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), transfers.clip.items.length])), + if (flows.transfers.clip.isNotEmpty) + ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), flows.transfers.clip.items.length])), ('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')), if (e.isDir && !ctl.c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path))) ('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')), @@ -190,45 +184,45 @@ class ExplorerItemMenu { ..active = ctl.panes.indexOf(other) ..update(); case 'playFolder': - await opener.playFolder(context, e.path); + await flows.opener.playFolder(context, e.path); case 'slideshow': - await opener.slideshow(context, pane, e.path); + await flows.opener.slideshow(context, pane, e.path); case 'playInternal': - await opener.play(context, [e.path], internal: true); + await flows.opener.play(context, [e.path], internal: true); case 'grid': - await opener.grid(context, pane, e.path); + await flows.opener.grid(context, pane, e.path); case 'text': - await opener.viewText(context, e.path); + await flows.opener.viewText(context, e.path); case 'sqlite': await openSqliteViewer(context, ctl.c, e.path); case 'vaultAdd': await openVault(context, ctl.c, add: targets, deleteOriginals: (paths, {required permanent}) { // 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더) - deletes.shiftDelete = permanent; - return deletes.run(context, pane, paths); + flows.deletes.shiftDelete = permanent; + return flows.deletes.run(context, pane, paths); }); await ctl.refreshAll([vDirname(e.path)]); case 'openWith': - final local = await opener.fetch(context, e.path); + final local = await flows.opener.fetch(context, e.path); if (local != null && !await ctl.c.services.shell.openWith(local, choose: true)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path)); case 'openDefault': - final local = await opener.fetch(context, e.path); + final local = await flows.opener.fetch(context, e.path); if (local != null && !await ctl.c.services.shell.openWith(local)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path)); case 'addMkv': - await cmd.addMkv([e.path]); + await flows.opener.addMkv([e.path]); case 'range': sel.markRange(pane, e); case 'select': sel.setSelecting(true); if (!sel.isMarked(pane, e.path)) await sel.toggleMark(pane, e); case 'quickLook': - await cmd.quickLook(pane, e.path); + await flows.opener.quickLook(context, pane, e.path); case 'extract': - await opener.extractArchive(context, e.path, pane); + await flows.opener.extractArchive(context, e.path, pane); case 'storage': - await cmd.openStorage(pane, e.path); + await flows.tools.openStorage(context, pane, e.path); case 'wifiShare': - await cmd.openWifiShare(e.path); + await flows.tools.openWifiShare(context, e.path); case 'zip': final items = outermostPaths(sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); final other = cmd.dual ? ctl.panes[1 - ctl.panes.indexOf(pane)].current : null; @@ -287,25 +281,25 @@ class ExplorerItemMenu { } case 'rename': if (sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) { - await cmd.batchRename(pane, pane.sel.marked); + await flows.renames.batch(context, pane, pane.sel.marked); } else { - await cmd.renameItem(pane, e.path); + await flows.renames.rename(context, pane, e.path); } case 'copy': - await transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false, otherDir: cmd.otherDir); + await flows.transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false, otherDir: cmd.otherDir); case 'move': - await transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true, otherDir: cmd.otherDir); + await flows.transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true, otherDir: cmd.otherDir); case 'clipCopy': - transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); + flows.transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'clipMove': - transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); + flows.transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'paste': - await transfers.paste(context, pane, e.isDir ? e.path : vDirname(e.path)); + await flows.transfers.paste(context, pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': // 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다) - await deletes.run(context, pane, targets); + await flows.deletes.run(context, pane, targets); case 'share': - await opener.share(context, targets); + await flows.opener.share(context, targets); case 'copyPath': final paths = targets.map((x) => copyablePath(ctl, x)).toList(); await Clipboard.setData(ClipboardData(text: paths.join('\n'))); @@ -316,9 +310,9 @@ class ExplorerItemMenu { ctl.host.snack(trf('즐겨찾기에 넣었습니다: {0}', [name])); ctl.update(); case 'view': - await opener.open(context, e.path, pane: pane); + await flows.opener.open(context, e.path, pane: pane); case 'toPdf': - await opener.makePdf(context, images); + await flows.opener.makePdf(context, images); case 'reveal': await ctl.c.services.shell.revealFile(e.path); case 'info': diff --git a/lib/ui/explorer/key_flow.dart b/lib/ui/explorer/key_flow.dart new file mode 100644 index 0000000..79abfe7 --- /dev/null +++ b/lib/ui/explorer/key_flow.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/file_ops.dart' show samePath; +import '../../core/vfs.dart' show vDirname; +import '../explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; +import 'explorer_buttons.dart'; +import 'explorer_controller.dart'; +import 'explorer_flows.dart'; +import 'explorer_pane.dart'; +import 'explorer_select.dart'; + +/// 약속 (E5d-6): 키보드 · 마우스로 하는 일. [bindings]: 동작마다의 키 → 할 일 (83 · 5-1 ⑥: 처음 키는 스타일대로, 사용자가 바꾼 것은 +/// 설정, Rsync 화면은 늘 같은 키). 글자 칸에 치는 중이면 키를 넘긴다 ([shortcut]). 목록에서 글자를 치면 빠른 필터가 그 글자로 +/// 열린다 ([typeToFilter], 76). 마우스 4 · 5번은 그 창의 뒤로 · 앞으로 ([mouseTravel], 75). 단축키를 받는 곳은 [focus] +class ExplorerInput { + ExplorerInput(this.ctl, this.sel, this.flows, this.buttons, {required this.dual}); + + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerFlows flows; + final ExplorerButtons buttons; + + /// 두 창으로 보이는지 ([창 바꾸기] 키) + final bool Function() dual; + + /// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다 + final focus = FocusNode(debugLabel: 'explorer'); + + void dispose() => focus.dispose(); + + /// 75: 마우스 4 · 5번 - 그 창의 뒤로 · 앞으로 (그 창을 활성으로). + /// 창 위의 누름은 창 ([ExplorerPaneFrame]) 과 화면 Listener 가 둘 다 받는다. 받는 곳마다 좌표를 바꾼 새 이벤트라 객체로는 같은지 알 수 없어 + /// 누른 손가락 (pointer) · 시각으로 한 번만 쓴다 (창이 먼저 받는다) + (int, Duration)? _navPress; + void mouseTravel(int i, PointerDownEvent ev) { + if (i >= ctl.panes.length) return; + final back = ev.buttons & kBackMouseButton != 0; + if (!back && ev.buttons & kForwardMouseButton == 0) return; + final press = (ev.pointer, ev.timeStamp); + if (press == _navPress) return; + _navPress = press; + ctl.travel(ctl.panes[i], forward: !back); + } + + /// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작) + void openFilter(ExplorerPane pane, [String? text]) { + ctl.active = ctl.panes.indexOf(pane); + pane.filterOpen = true; + if (text != null) { + pane.filterCtl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)); + } + ctl.update(); + WidgetsBinding.instance.addPostFrameCallback((_) => pane.filterFocus.requestFocus()); + // 거른 결과 (지금 폴더 아래) 가 보이게 + ctl.host.reveal(pane, pane.current); + } + + /// 76: 닫으면 글자도 지워 모두 보인다. 줄이 다시 늘어나 맨 위로 가지 않게 고른 것 (없으면 지금 폴더) 으로 + void closeFilter(ExplorerPane pane) { + pane + ..filterOpen = false + ..filterCtl.clear(); + ctl.update(); + ctl.host.reveal(pane, pane.focused ?? pane.current); + focus.requestFocus(); + } + + /// 83: 지금 스타일이 Total Commander 인지 (Rsync 화면은 스타일 단축키를 쓰지 않는다 - 늘 같은 버튼) + bool get _tcKeys => !ctl.rsync && Platform.isWindows; + + /// 83 · X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 동작마다의 키 → 할 일. 처음 키는 스타일대로 (Total Commander: F5 복사 · F6 이동 · + /// F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침), + /// 사용자가 바꾼 것은 설정 explorerKeyOverrides. Rsync 화면은 늘 같은 키 (바꾼 것을 쓰지 않음) + Map bindings(BuildContext context) { + final cb = { + 'copy': () => buttons.press(ExplorerButton.copy), + 'move': () => buttons.press(ExplorerButton.move), + 'newFolder': () => buttons.press(ExplorerButton.newFolder), + 'delete': () => buttons.press(ExplorerButton.delete), + // 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인) + 'deletePermanent': () { + flows.deletes.shiftDelete = true; + buttons.press(ExplorerButton.delete); + }, + 'rename': () => buttons.press(ExplorerButton.rename), + 'refresh': () => buttons.press(ExplorerButton.refresh), + // 81: 빠른 보기 + 'quickLook': () => flows.opener.quickLook(context, ctl.pane), + 'clipCopy': () => flows.transfers.toClipboard(sel.targets(ctl.pane), move: false), + 'clipCut': () => flows.transfers.toClipboard(sel.targets(ctl.pane), move: true), + 'paste': () => flows.transfers.paste(context, ctl.pane, ctl.pane.current), + 'selectAll': () { + if (!sel.selecting) sel.setSelecting(true); + sel.markWhere(ctl.pane, ctl.pane.current, (_) => true); + }, + // TC: Space 표시 · Insert 표시하고 다음 줄로 + 'mark': () => _markFocused(next: false), + 'markNext': () => _markFocused(next: true), + 'filter': () => openFilter(ctl.panes[ctl.active]), + 'up': () => buttons.press(ExplorerButton.up), + // 75: 지금 창의 뒤로 · 앞으로 + 'back': () => ctl.travel(ctl.panes[ctl.active], forward: false), + 'forward': () => ctl.travel(ctl.panes[ctl.active], forward: true), + 'switchPane': () { + ctl + ..active = dual() ? 1 - ctl.active : 0 + ..update(); + }, + }; + final keys = ctl.rsync + ? { + for (final e in defaultExplorerKeys(tc: false).entries) + if (const {'delete', 'deletePermanent', 'rename', 'refresh', 'filter', 'up', 'back', 'forward', 'switchPane'}.contains(e.key)) + e.key: e.value, + } + : effectiveExplorerKeys(tc: _tcKeys, overrides: ctl.c.settings.explorerKeyOverrides); + return { + for (final e in keys.entries) + if (cb[e.key] != null) + for (final a in e.value) a: cb[e.key]!, + }; + } + + /// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로 + Future _markFocused({required bool next}) async { + final pane = ctl.pane; + final f = pane.focused; + if (f == null || samePath(f, pane.root)) return; + final list = sel.shown(pane, vDirname(f)); + final i = list.indexWhere((x) => samePath(x.path, f)); + if (i < 0) return; + if (!sel.selecting) sel.setSelecting(true); + await sel.toggleMark(pane, list[i]); + if (next && i + 1 < list.length) { + pane.focused = list[i + 1].path; + pane.changed(); + ctl.host.reveal(pane, list[i + 1].path); + } + } + + /// 글자 칸에 치는 중인지 (그 칸이 키를 받아야 한다) + static bool get _typing => FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType() != null; + + /// 탐색기 단축키: 글자 칸에 치는 중이면 넘기고 (칸의 기본 동작 - 선택 · 복사 · 붙여넣기 · 지우기 · 빈칸), 아니면 맞는 것을 한다 + KeyEventResult shortcut(Map bindings, KeyEvent ev) { + if (ev is KeyUpEvent || _typing) return KeyEventResult.ignored; + for (final b in bindings.entries) { + if (b.key.accepts(ev, HardwareKeyboard.instance)) { + b.value(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + KeyEventResult typeToFilter(KeyEvent ev) { + if (ev is! KeyDownEvent) return KeyEventResult.ignored; + final ch = ev.character; + final kb = HardwareKeyboard.instance; + if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored; + // 83 · 81: Space 는 표시 (TC) · 빠른 보기 (그 밖) - 빠른 필터를 열지 않는다 (필터가 열려 있으면 그대로 글자) + if (ch == ' ' && !ctl.rsync && !ctl.panes[ctl.active].filterOpen) return KeyEventResult.ignored; + if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored; + // 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다 + if (_typing) return KeyEventResult.ignored; + if (!ctl.ready || ctl.panes.isEmpty) return KeyEventResult.ignored; + final pane = ctl.panes[ctl.active]; + openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch); + return KeyEventResult.handled; + } +} diff --git a/lib/ui/explorer/open_flow.dart b/lib/ui/explorer/open_flow.dart index d96a86b..72e7b6b 100644 --- a/lib/ui/explorer/open_flow.dart +++ b/lib/ui/explorer/open_flow.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -12,13 +13,13 @@ import '../../core/reader_sources.dart'; import '../../core/vfs.dart'; import '../file_error.dart' show explainFileError; import '../player_page.dart' show playFiles; -import '../quick_look.dart' show textExtensions; import '../../l10n/tr.dart'; import '../../platform/android/hwp_pdf_ffi.dart' show RhwpEngine; import '../archive_ui.dart'; import '../dav_external.dart'; import '../explorer_grid.dart'; import '../progress_dialog.dart'; +import '../quick_look.dart'; import '../reader_page.dart'; import '../text_viewer.dart'; import '../zip_page.dart'; @@ -317,4 +318,52 @@ class ExplorerOpener { ctl.host.snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length])); } } + + /// 동영상 (폴더면 그 안의 것) 을 MKV 목록에 - 원격은 넣지 않고 알림, 읽지 못한 폴더는 건너뛰고 알림 + Future addMkv(List paths) async { + if (paths.any(isDav)) { + ctl.host.snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.')); + return; + } + final videos = []; + final unreadable = []; + for (final x in paths) { + if (vIsDirSync(x)) { + try { + videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path)); + } catch (_) { + unreadable.add(vBasename(x)); // 읽지 못한 폴더는 건너뛰고 알린다 + } + } else if (isVideoFile(x)) { + videos.add(x); + } + } + if (unreadable.isNotEmpty) ctl.host.snack(trf('읽을 수 없어 건너뛴 폴더: {0}', [unreadable.take(3).join(', ')])); + if (videos.isEmpty) { + if (unreadable.isEmpty) ctl.host.snack(tr('추가할 동영상이 없습니다.')); + return; + } + await ctl.c.addVideos(videos, allowOutputFolder: true); + ctl.host.snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length])); + } + + /// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로 + Future quickLook(BuildContext context, ExplorerPane pane, [String? path]) async { + final f = path ?? pane.focused; + if (f == null || samePath(f, pane.root)) return; + final list = sel.shown(pane, vDirname(f)); + final i = list.indexWhere((x) => samePath(x.path, f)); + if (i < 0) return; + await showDialog( + context: context, + builder: (_) => QuickLook( + entries: list, + index: i, + probe: ctl.c.services.mediaTool.probe, + thumbnail: (e) => BigVideoThumbs.of(ctl.c, e), + imageExts: ctl.c.settings.imageExts, + onOpen: (e) => e.isDir ? unawaited(ctl.goTo(pane, e.path)) : unawaited(open(context, e.path, pane: pane)), + ), + ); + } } diff --git a/lib/ui/explorer/pane_notices.dart b/lib/ui/explorer/pane_notices.dart new file mode 100644 index 0000000..666a07d --- /dev/null +++ b/lib/ui/explorer/pane_notices.dart @@ -0,0 +1,275 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../core/file_ops.dart'; +import '../../core/ftp.dart' show FtpRegistry; +import '../../core/secret_gate.dart'; +import '../../core/sftp.dart' show SftpRegistry; +import '../../core/shizuku.dart'; +import '../../core/smb.dart' show SmbRegistry; +import '../../core/vfs.dart'; +import '../../core/webdav.dart' show DavRegistry; +import '../../l10n/tr.dart'; +import '../../platform/android/android_storage.dart'; +import '../file_error.dart'; +import '../ftp_settings.dart'; +import '../sftp_settings.dart'; +import '../theme.dart'; +import 'access_flow.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'server_flows.dart'; + +/// 약속 (E5d-6): 창 목록 위의 안내 상자들 ([of] - 없는 것은 null). 시스템 폴더 · 루트를 보는 동안 (읽기 전용) · 권한이 없을 때 +/// (허용 화면 · 다시 확인) · 읽지 못할 때 (까닭 · [다시 시도] · [서버 설정 고치기] · 처음 보는 서버 키 · 인증서 지문) · +/// 만들다 만 파일 (지우기). 안내의 단추가 하는 일은 [ExplorerAccess] · [ExplorerServers] · [ExplorerController] +class ExplorerNotices { + ExplorerNotices(this.ctl, this.access, this.servers); + + final ExplorerController ctl; + final ExplorerAccess access; + final ExplorerServers servers; + + List of(BuildContext context, ExplorerPane pane, String dir) => + [_shizuku(pane), _access(pane), _error(context, pane, dir), _partial(context, pane, dir)]; + + /// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인) + /// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠 + Widget? _shizuku(ExplorerPane pane) { + if (!isShizuku(pane.current)) return null; + final root = isRootPath(pane.current); + return _notice( + icon: root ? Icons.security : Icons.admin_panel_settings_outlined, + title: root ? tr('루트 (su) - 읽기 전용') : tr('시스템 폴더 (Shizuku) - 읽기 전용'), + body: root + ? tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요') + : tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'), + actions: const [], + color: JjColors.warning, + ); + } + + Widget? _access(ExplorerPane pane) { + if (!Platform.isAndroid || access.allFiles != false || isDav(pane.root)) return null; + return _notice( + icon: Icons.lock_outline, + title: tr('파일이 보이지 않습니다: "모든 파일에 대한 접근" 권한이 없습니다'), + body: access.dualApp + ? tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. ' + '허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.') + : tr('폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.'), + actions: [ + FilledButton.tonal(onPressed: AndroidAccess.request, child: Text(tr('권한 허용 화면 열기'))), + TextButton(onPressed: () => access.checkAccess(reload: true), child: Text(tr('허용했으면 다시 확인'))), + ], + ); + } + + /// 목록 위 안내 상자 + Widget _notice( + {required IconData icon, + required String title, + required String body, + required List actions, + Color? color}) => + _noticeBox(icon, title, body, actions, color ?? JjColors.alert); + + Widget _noticeBox(IconData icon, String title, String body, List actions, Color color) => + Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(6, 6, 6, 0), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), + Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))), + ]), + const SizedBox(height: 4), + Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + Wrap(spacing: 8, children: actions), + ]), + ); + + // ───────── 만들다 만 파일 (71) ───────── + + /// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고 + List _partials(ExplorerPane pane, String dir) { + final old = DateTime.now().subtract(const Duration(minutes: 2)); + return [ + for (final e in pane.cache[dir] ?? const []) + if (!e.isDir && isPartialFile(e.name) && e.modified.isBefore(old)) e, + ]; + } + + Widget? _partial(BuildContext context, ExplorerPane pane, String dir) { + final list = _partials(pane, dir); + if (list.isEmpty) return null; + return _notice( + icon: Icons.broken_image_outlined, + color: JjColors.warning, + title: trf('만들다 만 파일 {0}개', [list.length]), + body: tr('복사 · 동기화가 중간에 끊겨 남은 임시 파일입니다 (.jjpart). 완성된 파일이 아니므로 지워도 됩니다. ' + '같은 파일을 다시 복사하면 새로 만듭니다.'), + actions: [ + TextButton( + onPressed: () async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(trf('만들다 만 파일 {0}개를 지울까요?', [list.length])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (final e in list.take(50)) Text(e.name, style: const TextStyle(fontSize: 12)), + if (list.length > 50) Text(trf('… 외 {0}개', [list.length - 50])), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), + ], + ), + ); + if (ok != true) return; + final failed = []; + for (final e in list) { + try { + await vDelete(e.path); + } catch (_) { + failed.add(e.name); + } + } + // 같은 폴더를 보는 다른 창도 새로 + for (final q in ctl.panes) { + if (q == pane || q.cache.containsKey(dir)) await ctl.load(q, dir, force: true); + } + if (failed.isNotEmpty) ctl.host.snack(trf('지우지 못한 파일: {0}', [failed.join(', ')])); + }, + child: Text(tr('지우기')), + ), + ], + ); + } + + // ───────── 읽을 수 없을 때 (12) ───────── + + /// 오류 → (무엇이 문제인지, 무엇을 하면 되는지) + Widget? _error(BuildContext context, ExplorerPane pane, String dir) { + final err = pane.errors[dir] ?? pane.errors[pane.root]; + if (err == null) return null; + final where = pane.errors[dir] != null ? dir : pane.root; + final dav = isDav(where); + final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null; + final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null; + final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null; + final ftp = isFtp(where) ? FtpRegistry.server(DavPath.parse(where).server) : null; + // l: 처음 보는 FTPS 자체 서명 인증서 - 지문을 보이고 [지문 확인하고 연결] (바뀐 인증서는 막고 서버 설정으로) + final cert = pane.certErrors[where] ?? pane.certErrors[pane.root]; + if (ftp != null && cert != null) { + return _notice( + icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint, + title: '${ctl.displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}', + body: cert.message, + actions: [ + if (!cert.changed) + FilledButton.tonal( + onPressed: () async { + if (!await askTrustFtpCert(context, ftp.label, cert)) return; + await trustFtpCert(ctl.c, ftp.id, cert); + pane.certErrors.remove(where); + pane.errors.remove(where); + pane.changed(); + await ctl.load(pane, where, force: true); + }, + child: Text(tr('지문 확인하고 연결')), + ), + TextButton(onPressed: () => servers.editFtp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))), + ], + ); + } + // 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로) + final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root]; + if (sftp != null && hostKey != null) { + return _notice( + icon: hostKey.changed ? Icons.gpp_bad_outlined : Icons.fingerprint, + title: '${ctl.displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}', + body: hostKey.message, + actions: [ + if (!hostKey.changed) + FilledButton.tonal( + onPressed: () async { + if (!await askTrustHostKey(context, sftp.label, hostKey)) return; + await trustSftpHostKey(ctl.c, sftp.id, hostKey); + pane.hostKeyErrors.remove(where); + pane.errors.remove(where); + pane.changed(); + await ctl.load(pane, where, force: true); + }, + child: Text(tr('지문 확인하고 연결')), + ), + TextButton(onPressed: () => servers.editSftp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))), + ], + ); + } + final (title, body) = explainFileError(err, + dav: dav, + noPassword: (server != null && server.password.isEmpty) || + (sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) || + (smb != null && smb.user.isNotEmpty && smb.password.isEmpty) || + (ftp != null && ftp.user.isNotEmpty && ftp.password.isEmpty)); + final locked = isLockedError(err); + // 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로] + if (err == androidRestrictedError) { + return _notice( + icon: Icons.lock_outline, + title: '${ctl.displayPath(where)}: $title', + body: body, + actions: [ + FilledButton.tonal( + onPressed: () { + pane.errors.remove(where); + ctl.goTo(pane, vDirname(where)); + }, + child: Text(tr('위 폴더로')), + ), + ], + ); + } + return _notice( + icon: dav ? Icons.cloud_off_outlined : Icons.error_outline, + title: '${ctl.displayPath(where)}: $title', + body: body, + actions: [ + FilledButton.tonal( + onPressed: () async { + // 133: 마스터를 취소해 막혔으면 여기서 다시 묻는다 + if (locked && !await SecretGate.pass(force: true)) return; + pane.errors.remove(where); + pane.changed(); + await ctl.load(pane, where, force: true); + for (final d in pane.expanded.toList()) { + if (isSameOrInside(d, where) && d != where) await ctl.load(pane, d, force: true); + } + }, + child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')), + ), + // 시스템 폴더 (Shizuku) 는 고칠 서버 설정이 없다 + if (dav && !locked && !isShizuku(where)) + TextButton( + onPressed: () => sftp != null + ? servers.editSftp(context, pane, pane.root) + : smb != null + ? servers.editSmb(context, pane, pane.root) + : ftp != null + ? servers.editFtp(context, pane, pane.root) + : servers.editDav(context, pane, pane.root), + child: Text(tr('서버 설정 고치기'))), + ], + ); + } +} diff --git a/lib/ui/explorer/pane_view.dart b/lib/ui/explorer/pane_view.dart index 3741987..5507ebc 100644 --- a/lib/ui/explorer/pane_view.dart +++ b/lib/ui/explorer/pane_view.dart @@ -376,3 +376,57 @@ class ExplorerExtraRow extends StatelessWidget { ); } } + +/// 창 하나의 틀 - 지금 창 테두리 · 누르면 지금 창 · 마우스 4 · 5번 ([onPointer]) · 82: 빈 곳에 놓으면 그 창의 지금 폴더로 +/// (놓을 수 있으면 강조, 못 놓으면 빨간 테두리) +class ExplorerPaneFrame extends StatelessWidget { + const ExplorerPaneFrame( + {super.key, required this.ctl, required this.index, required this.onPointer, required this.onDrop, required this.children}); + + final ExplorerController ctl; + final int index; + final void Function(int index, PointerDownEvent ev) onPointer; + final Future Function(DragLoad load, int to, String dest) onDrop; + final List children; + + @override + Widget build(BuildContext context) { + final i = index; + final active = i == ctl.active; + return Listener( + // 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도) + onPointerDown: (ev) => onPointer(i, ev), + child: GestureDetector( + onTapDown: (_) { + if (ctl.active != i) { + ctl + ..active = i + ..update(); + } + }, + // 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리 + child: DragTarget( + onWillAcceptWithDetails: (d) => canDropInto(d.data, i, ctl.panes[i].current), + onAcceptWithDetails: (d) => unawaited(onDrop(d.data, i, ctl.panes[i].current)), + builder: (context, ok, no) => Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg, + border: Border.all( + color: ok.isNotEmpty + ? JjColors.accent + : no.isNotEmpty + ? JjColors.danger + : active + ? JjColors.accent + : JjColors.border, + width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)), + borderRadius: BorderRadius.circular(6), + ), + child: Column(children: children), + ), + ), + ), + ); + } +} diff --git a/lib/ui/explorer/rename_flow.dart b/lib/ui/explorer/rename_flow.dart new file mode 100644 index 0000000..089e481 --- /dev/null +++ b/lib/ui/explorer/rename_flow.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../../core/batch_rename.dart'; +import '../../core/file_ops.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../batch_rename_dialog.dart'; +import '../notice.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'name_dialog.dart'; +import 'write_guard.dart'; + +/// 약속 (E5d-6): 이름 바꾸기 - 하나 ([rename], 이름 창) · 여럿 ([batch], 78: 번호는 목록에 보이는 차례대로, 끝나면 [되돌리기]). +/// 바꾼 뒤 목록을 다시 읽고 표시 · 초점 · 숨긴 목록을 새 이름으로. 읽기 전용인 곳은 [ExplorerGuard] 가 막고, 못 하면 까닭을 알림 +class ExplorerRenames { + ExplorerRenames(this.ctl, this.guard); + + final ExplorerController ctl; + final ExplorerGuard guard; + + Future rename(BuildContext context, ExplorerPane pane, String path) async { + if (guard.blockArchive([path])) return; + final name = await askName(context, tr('이름 변경'), vBasename(path)); + if (name == null || name == vBasename(path)) return; + try { + final now = await FileOps.rename(path, name); + await ctl.followHidden(moved: [(path, now)]); + await ctl.refreshAll([vDirname(path)]); + for (final x in ctl.panes) { + x.sel.rename(path, now); + } + pane.focused = now; + pane.changed(); + } catch (e) { + ctl.host.snack(trf('이름을 바꿀 수 없습니다: {0}', [e])); + } + } + + /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] + Future batch(BuildContext context, ExplorerPane pane, List paths) async { + if (guard.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; + } + + final ordered = [...paths]..sort((a, b) { + final d = vDirname(a).compareTo(vDirname(b)); + return d != 0 ? d : at(a).compareTo(at(b)); + }); + final done = await showBatchRename(context, ordered, + isDir: vIsDirSync, + initial: RenameRule.fromJson(ctl.c.settings.batchRenameLast), + onRun: (r) => ctl.c.updateSettings((s) => s.batchRenameLast = r.toJson())); + if (done == null || done.isEmpty || !context.mounted) return; + await _after(pane, done); + if (!context.mounted) return; + showNotice(ScaffoldMessenger.of(context), + text: trf('{0}개의 이름을 바꿨습니다.', [done.length]), + action: ( + tr('되돌리기'), + () async { + try { + final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]); + if (context.mounted) await _after(pane, back); + } on BatchRenameException catch (e) { + ctl.host.snack(trf('되돌리지 못했습니다: {0}', [e])); + } + } + )); + } + + /// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로 + Future _after(ExplorerPane pane, List<(String, String)> moved) async { + await ctl.followHidden(moved: moved); + await ctl.refreshAll({for (final (a, _) in moved) vDirname(a)}); + for (final x in ctl.panes) { + for (final (from, to) in moved) { + x.sel.rename(from, to); + if (x.focused != null && samePath(x.focused!, from)) x.focused = to; + } + x.changed(); + } + } +} diff --git a/lib/ui/explorer/search_flow.dart b/lib/ui/explorer/search_flow.dart new file mode 100644 index 0000000..b94540e --- /dev/null +++ b/lib/ui/explorer/search_flow.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; + +import '../../core/file_ops.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../explorer_search.dart'; +import '../theme.dart'; +import 'delete_flow.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'open_flow.dart'; +import 'transfer_flow.dart'; + +/// 약속 (E5d-6): 찾기 (79) - 조건 창 → 결과 화면 (누르면 열기 · 재생, 고른 것만 복사 · 이동 · 지우기 - 탐색기의 흐름 그대로, [그 자리로]). +/// 결과 화면에서 무엇을 한 뒤에는 위 줄 ([backBar]) 의 [찾기 결과로 돌아가기] 로 다시 찾지 않고 돌아간다 (그사이 지운 것은 빼고 옮긴 것은 새 자리로). +/// 마지막 조건은 이번 실행 동안 다음 찾기의 처음 값. 돌아갈 결과가 바뀌면 알림 +class ExplorerSearch extends ChangeNotifier { + ExplorerSearch(this.ctl, this.opener, this.transfers, this.deletes, {required this.otherDir}); + + final ExplorerController ctl; + final ExplorerOpener opener; + final ExplorerTransfers transfers; + final ExplorerDeleteFlow deletes; + + /// 복사 · 이동 갈 곳 (없으면 담아 두기) + final String? Function() otherDir; + + void _setBack(({ExplorerPane pane, String root, SearchConditions cond, List found, bool done, Map moved})? v) { + _back = v; + notifyListeners(); + } + + /// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값) + SearchConditions _last = const SearchConditions(); + + /// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐) + ({ExplorerPane pane, String root, SearchConditions cond, List found, bool done, Map moved})? _back; + + /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) + Future open(BuildContext context, ExplorerPane pane) async { + ctl + ..active = ctl.panes.indexOf(pane) + ..update(); + final cond = await askSearchConditions(context, pane.current, _last); + if (cond == null || !context.mounted) return; + _last = cond; + _setBack(null); + await _page(context, pane, pane.current, cond); + } + + /// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고, 그사이 지운 것은 빼고 옮긴 것은 새 자리로 + Future backToResults(BuildContext context) async { + final back = _back; + if (back == null) return; + final now = []; + for (final e in back.found) { + if (await vExists(e.path)) { + now.add(e); + continue; + } + final dest = back.moved[e.path]; + final there = dest == null ? null : vJoin(dest, vBasename(e.path)); + if (there != null && await vExists(there)) { + now.add(FileEntry(there, isDir: e.isDir, size: e.size, modified: e.modified)); + } + // 고른 폴더와 함께 옮겨진 안쪽 것: 옮긴 폴더 아래 같은 자리 + else { + for (final entry in back.moved.entries) { + if (!isSameOrInside(e.path, entry.key)) continue; + // 옮긴 폴더 이름 아래 남은 경로 (같은 꼴의 경로라 앞부분을 잘라 쓴다 - WebDAV 도) + final rest = e.path.length > entry.key.length ? e.path.substring(entry.key.length + 1) : ''; + final moved = rest.isEmpty ? vJoin(entry.value, vBasename(entry.key)) : vJoin(vJoin(entry.value, vBasename(entry.key)), rest); + if (await vExists(moved)) now.add(FileEntry(moved, isDir: e.isDir, size: e.size, modified: e.modified)); + break; + } + } + } + if (!context.mounted) return; + await _page(context, back.pane, back.root, back.cond, initial: now, initialDone: back.done); + } + + Future _page(BuildContext context, ExplorerPane pane, String root, SearchConditions cond, {List? initial, bool initialDone = true}) async { + final other = otherDir(); + final act = await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => SearchResultsPage( + root: root, + cond: cond, + showHidden: ctl.c.settings.explorerShowHidden, + otherDir: other, + onOpen: (e) => opener.open(context, e.path, pane: pane), + initial: initial, + initialDone: initialDone, + ), + )); + if (!context.mounted) return; + // 결과 화면을 그냥 닫으면 돌아갈 결과도 없앤다 + if (act == null || act.paths.isEmpty) { + _setBack(null); + return; + } + // 관리자 판단 (10/17): [그 자리로] 로 나가도 남긴다 - 한 곳을 확인하고 다음 결과로 가는 것이 가장 흔한 쓰임 + { + _setBack(( + pane: pane, + root: root, + cond: cond, + found: act.found, + done: act.done, + moved: act.kind == 'move' && other != null ? {for (final x in act.paths) x: other} : const {}, + )); + } + switch (act.kind) { + case 'reveal': + final path = act.paths.first; + final dir = await vIsDir(path); + await ctl.goTo(pane, dir ? path : vDirname(path), fresh: true); + pane.focused = path; + pane.changed(); + ctl.host.reveal(pane, path); + // 고른 것만 대상 (154 · 155) - 탐색기의 복사 · 이동 · 지우기와 같은 확인 · 진행 패널 · 휴지통 + case 'copy': + await transfers.transfer(context, pane, act.paths, move: false, otherDir: otherDir()); + case 'move': + await transfers.transfer(context, pane, act.paths, move: true, otherDir: otherDir()); + case 'delete': + await deletes.run(context, pane, act.paths); + } + } + + /// 79-a: 탐색기 위의 [찾기 결과로 돌아가기] 줄 + Widget? backBar(BuildContext context) { + final back = _back; + if (back == null) return null; + return Material( + color: JjColors.panelHigh, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row(children: [ + Icon(Icons.manage_search, size: 18, color: JjColors.accent), + const SizedBox(width: 6), + Expanded( + child: Text(trf('찾기 결과 ({0}) · {1}', [back.cond.summary, back.root]), + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + TextButton(onPressed: () => backToResults(context), child: Text(tr('찾기 결과로 돌아가기'))), + IconButton( + tooltip: tr('찾기 결과 닫기'), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 16), + onPressed: () => _setBack(null), + ), + ]), + ), + ); + } +} diff --git a/lib/ui/explorer/tools_flow.dart b/lib/ui/explorer/tools_flow.dart new file mode 100644 index 0000000..93ef0c8 --- /dev/null +++ b/lib/ui/explorer/tools_flow.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../core/vfs.dart'; +import '../../core/wifi_share.dart' show WifiShare; +import '../../l10n/tr.dart'; +import '../storage_page.dart'; +import '../theme.dart'; +import '../wifi_share_page.dart'; +import 'delete_flow.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; +import 'transfer_flow.dart'; + +/// 약속 (E5d-6): 탐색기에서 여는 도구 - 저장 공간 분석 (87: 고른 것을 탐색기에서 그 자리로, 지우기 · 이동은 탐색기의 흐름으로, +/// 원격은 오래 걸린다고 먼저 묻는다) · Wi-Fi 공유 (89: 이 기기의 폴더만, 켜져 있으면 그 공유). 위의 [Wi-Fi 공유 중] 줄은 [WifiShareBar] +class ExplorerTools { + ExplorerTools(this.ctl, this.deletes, this.transfers); + + final ExplorerController ctl; + final ExplorerDeleteFlow deletes; + final ExplorerTransfers transfers; + + /// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만). + /// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다 + Future openStorage(BuildContext context, ExplorerPane pane, String dir) async { + ctl + ..active = ctl.panes.indexOf(pane) + ..update(); + if (isDav(dir)) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('저장 공간 분석')), + content: Text(tr('WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('분석'))), + ], + ), + ); + if (ok != true || !context.mounted) return; + } + // X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로) + final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async { + await deletes.run(context, pane, paths); + return [for (final x in paths) if (!await vExists(x)) x]; + }, onCut: (paths) => transfers.toClipboard(paths, move: true)); + if (path == null || !context.mounted) return; + final isDir = await vIsDir(path); + await ctl.goTo(pane, isDir ? path : vDirname(path), fresh: true); + pane.focused = path; + pane.changed(); + ctl.host.reveal(pane, path); + } + + /// 89: Wi-Fi 공유 화면 (이미 켜져 있으면 그 공유를 보인다). 이 기기의 폴더만 - WebDAV 등 원격은 알림 + Future openWifiShare(BuildContext context, String dir) async { + final on = WifiShare.instance; + if (on == null && isDav(dir)) { + ctl.host.snack(tr('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다')); + return; + } + await WifiSharePage.open(context, ctl.c, on?.root ?? dir); + } +} + +/// 89: 탐색기 위의 [Wi-Fi 공유 중] 줄 - 누르면 공유 화면, [끄기] +class WifiShareBar extends StatelessWidget { + const WifiShareBar({super.key, required this.c, required this.share}); + + final AppController c; + final WifiShare share; + + // 화면을 닫아도 켜져 있음을 늘 보이게 (사용자가 모르게 열려 있지 않게) + @override + Widget build(BuildContext context) { + final s = share; + final left = s.endsAt?.difference(DateTime.now()); + return Material( + color: JjColors.panelHigh, + child: InkWell( + onTap: () => WifiSharePage.open(context, c, s.root), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row(children: [ + Icon(Icons.wifi_tethering, size: 18, color: JjColors.success), + const SizedBox(width: 6), + Expanded( + child: Text( + left == null + ? trf('Wi-Fi 공유 중 · 코드 {0} · {1}', [s.code, s.root]) + : trf('Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}', [s.code, (left.inSeconds / 60).ceil().clamp(0, 9999), s.root]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12)), + ), + TextButton(onPressed: () => s.stop(), child: Text(tr('끄기'))), + ]), + ), + ), + ); + } +} diff --git a/lib/ui/explorer/view_dialogs.dart b/lib/ui/explorer/view_dialogs.dart new file mode 100644 index 0000000..ccde0b4 --- /dev/null +++ b/lib/ui/explorer/view_dialogs.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; + +import '../../app/volumes.dart' show reachable; +import '../../l10n/tr.dart'; +import '../explorer_bookmarks.dart'; +import '../theme.dart'; +import 'explorer_buttons.dart'; +import 'explorer_controller.dart'; +import 'explorer_pane.dart'; + +/// 약속 (E5d-6): 보기를 바꾸는 창 - 정렬 기준 · 즐겨찾기 (Rsync 는 짝) · 내역 · 창 배치 (좌우 ⇆ 위아래 한 번에 · 배치 창) · 버튼 구성. +/// 고른 것은 설정에 저장하고 (취소면 그대로), 배치가 바뀌면 보던 폴더가 다시 보이게 한다. [twoPanes]: 지금 창이 둘인지 +class ExplorerViewDialogs { + ExplorerViewDialogs(this.ctl, {required this.twoPanes}); + + final ExplorerController ctl; + final bool Function() twoPanes; + + /// 84: 정렬 기준 ("폴더마다 기억" 이면 지금 창의 폴더) + Future sortDialog(BuildContext context) async { + // 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬 + final dir = ctl.pane.current; + var (by, desc) = ctl.sortOf(dir); + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + scrollable: true, + title: Text(tr('정렬 기준')), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Wrap(spacing: 6, children: [ + for (final (v, label) in [ + ('name', tr('이름')), + ('date', tr('바뀐 날')), + ('size', tr('크기')), + ('type', tr('종류 (확장자)')), + ]) + ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)), + ]), + SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)), + if (ctl.c.settings.explorerSortPerFolder) + Text(trf('이 폴더에만: {0}', [ctl.displayPath(dir)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), + ], + ), + ), + ); + if (ok != true) return; + await ctl.setSort(dir, by, desc); + } + + /// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에 + Future openBookmarks(BuildContext context, ExplorerPane pane) async { + ctl + ..active = ctl.panes.indexOf(pane) + ..update(); + await showFolderBookmarks( + context, + ctl.c, + current: pane.current, + right: ctl.rsync ? ctl.panes[1].current : null, + go: (dir) => ctl.goTo(pane, dir, fresh: true), + goPair: ctl.rsync + ? (a, b) { + ctl.goTo(ctl.panes[0], a, fresh: true); + ctl.goTo(ctl.panes[1], b, fresh: true); + } + : null, + ); + if (context.mounted) ctl.update(); + } + + Future historyDialog(BuildContext context, ExplorerPane pane) async { + final hist = ctl.c.settings.explorerHistory.where(reachable).toList(); + final pick = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text(tr('내역 (최근 폴더)')), + children: [ + if (hist.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('없음'))), + for (final h in hist) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, h), + child: Text(h, maxLines: 2, overflow: TextOverflow.ellipsis), + ), + ], + ), + ); + if (pick != null) await ctl.goTo(pane, pick, fresh: true); + } + + /// 118: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로 + Future toggleOrientation(BuildContext context) async { + final s = ctl.c.settings; + if (!twoPanes()) { + await ctl.c.updateSettings((x) => x + ..explorerLayout = 'dual' + ..explorerOrientation = 'side'); + } else { + // 지금 보이는 배치의 반대 ('화면 모양 따라' 였으면 지금 모양에서 정함) + final size = MediaQuery.sizeOf(context); + final nowSide = switch (s.explorerOrientation) { + 'side' => true, + 'stacked' => false, + _ => size.width >= size.height, + }; + await ctl.c.updateSettings((x) => x.explorerOrientation = nowSide ? 'stacked' : 'side'); + } + if (!context.mounted) return; + ctl.update(); + for (final pane in ctl.panes) { + ctl.host.reveal(pane, pane.current); + } + } + + Future layoutDialog(BuildContext context) async { + final s = ctl.c.settings; + var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) { + Widget group(String title, String value, List<(String, String)> items, void Function(String) on) => + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 8, left: 4), + child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Wrap(spacing: 6, children: [ + for (final (v, label) in items) + ChoiceChip(label: Text(label), selected: value == v, onSelected: (_) => set(() => on(v))), + ]), + ]); + return AlertDialog( + scrollable: true, + title: Text(tr('창 배치')), + content: SizedBox( + width: 420, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + group(tr('창'), layout, [ + ('auto', tr('화면 크기 따라')), + ('dual', tr('두 창 (기본)')), + ('split', tr('폴더 + 파일 목록')), + ('single', tr('한 창')), + ], (v) => layout = v), + if (layout == 'split') + Padding( + padding: const EdgeInsets.only(top: 6, left: 4), + child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + if (layout != 'single') + group(tr('두 창 배치'), orient, [ + ('side', tr('좌우 (기본)')), + ('stacked', tr('위아래')), + ('auto', tr('화면 모양 따라')), + ], (v) => orient = v), + group(tr('기능 버튼 줄'), bar, [ + ('middle', layout != 'single' ? tr('두 창 사이') : tr('왼쪽')), + ('edge', tr('끝 (오른쪽 · 아래)')), + ('hidden', tr('숨김')), + ], (v) => bar = v), + if (bar == 'hidden') + Padding( + padding: const EdgeInsets.only(top: 8, left: 4), + child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), + ], + ); + }, + ), + ); + if (ok != true) return; + await ctl.c.updateSettings((x) => x + ..explorerLayout = layout + ..explorerOrientation = orient + ..explorerToolbar = bar); + if (layout == 'single') { + ctl + ..active = 0 + ..update(); + } + // 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게 + for (final pane in ctl.panes) { + ctl.host.reveal(pane, pane.current); + } + } + + Future buttonsDialog(BuildContext context) async { + final current = ExplorerButton.fromSettings(ctl.c.settings.explorerButtons); + final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))]; + final on = {...current}; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(tr('버튼 구성')), + content: SizedBox( + width: 380, + height: 460, + child: Column(children: [ + Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 8), + Expanded( + child: ReorderableListView( + buildDefaultDragHandles: true, + onReorderItem: (a, b) => set(() => order.insert(b, order.removeAt(a))), + children: [ + for (final b in order) + CheckboxListTile( + key: ValueKey(b), + dense: true, + value: on.contains(b), + secondary: Icon(b.icon), + title: Text(tr(b.label)), + controlAffinity: ListTileControlAffinity.leading, + onChanged: (v) => set(() => v == true ? on.add(b) : on.remove(b)), + ), + ], + ), + ), + ]), + ), + actions: [ + TextButton( + onPressed: () => set(() { + order + ..clear() + ..addAll(ExplorerButton.defaultOrder); + on + ..clear() + ..addAll(ExplorerButton.explorerValues); + }), + child: Text(tr('기본값')), + ), + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), + ], + ), + ), + ); + if (ok != true) return; + final names = [for (final b in order) if (on.contains(b)) b.name]; + await ctl.c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 8dde205..902a740 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -4,47 +4,17 @@ export 'explorer/explorer_buttons.dart' show ExplorerButton; export 'explorer/transfer_panel.dart' show TransferProgressChip; import 'dart:async'; import 'dart:io'; -import 'dart:math' as math; -import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../app/app_controller.dart'; import '../app/copy_center.dart'; -import '../app/transfer_job.dart'; -import '../core/secret_gate.dart'; import '../core/file_ops.dart'; -import '../core/playlist.dart' show isVideoFile; -import '../core/vfs.dart'; -import '../core/sftp.dart' show SftpRegistry; -import '../core/ftp.dart' show FtpRegistry; -import '../core/smb.dart' show SmbRegistry; -import '../core/webdav.dart' show DavRegistry; -import '../platform/android/android_storage.dart'; import 'app_actions.dart'; -import 'explorer_look.dart'; -import 'theme.dart'; -import 'file_error.dart'; -import 'folder_picker.dart' show pickFolderOrDav; -import 'path_label.dart'; -import 'sftp_settings.dart'; -import 'ftp_settings.dart'; -import '../l10n/tr.dart'; -import 'explorer_bookmarks.dart'; -import 'explorer_search.dart'; -import 'tail_text.dart'; import '../core/thumb_cache.dart' show thumbMemoryForgetters; -import '../core/shizuku.dart'; -import 'explorer_keys.dart' show defaultExplorerKeys, effectiveExplorerKeys; -import 'quick_look.dart'; import 'explorer_drag.dart'; -import 'storage_page.dart'; -import 'wifi_share_page.dart'; import '../core/wifi_share.dart' show WifiShare; -import 'batch_rename_dialog.dart'; -import '../core/batch_rename.dart'; -import 'notice.dart'; import '../app/volumes.dart'; import 'explorer/explorer_controller.dart'; import 'explorer/explorer_host.dart'; @@ -55,17 +25,17 @@ import 'explorer/path_dialog.dart'; import 'explorer/pane_header.dart'; import 'explorer/pane_view.dart'; import 'explorer/explorer_buttons.dart'; +import 'explorer/explorer_flows.dart'; +import 'explorer/explorer_bars.dart' show ExplorerTopBar; +import 'explorer/explorer_body.dart'; +import 'explorer/explorer_layout.dart'; +import 'explorer/key_flow.dart'; +import 'explorer/tools_flow.dart' show WifiShareBar; +import 'explorer/access_flow.dart'; +import 'explorer/pane_notices.dart'; import 'explorer/item_menu.dart'; -import 'explorer/name_dialog.dart'; -import 'explorer/open_flow.dart'; -import 'explorer/delete_flow.dart'; -import 'explorer/explorer_jobs.dart'; -import 'explorer/rsync_flow.dart'; -import 'explorer/transfer_flow.dart'; -import 'explorer/write_guard.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; @@ -84,8 +54,6 @@ export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText; /// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 - /// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다 -/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩) -const double rsyncTwoPaneMinWidth = 560; class ExplorerPage extends StatefulWidget { final AppController c; @@ -115,11 +83,11 @@ class ExplorerPage extends StatefulWidget { @visibleForTesting static bool? debugNomedia; - /// 이번 실행에서 시스템 폴더 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번) - static bool shizukuWarnedThisRun = false; - - /// 루트도 같게 (따로 센다) - static bool rootWarnedThisRun = false; + /// 이번 실행에서 시스템 폴더 · 루트 경고를 보였는지 ([ExplorerAccess]) + static bool get shizukuWarnedThisRun => ExplorerAccess.shizukuWarned; + static set shizukuWarnedThisRun(bool v) => ExplorerAccess.shizukuWarned = v; + static bool get rootWarnedThisRun => ExplorerAccess.rootWarned; + static set rootWarnedThisRun(bool v) => ExplorerAccess.rootWarned = v; /// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유) @visibleForTesting @@ -176,28 +144,15 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex @override void snack(String text) => _snack(text); @override - void reveal(ExplorerPane pane, String path) => _reveal(pane, path); + void reveal(ExplorerPane pane, String path) => _layout.reveal(pane, path); @override void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); - Future _probeDrive(String path) => _ctl.drives.check(path, ExplorerPage.probeDrive); + /// 창 배치 규칙 · 스크롤 (E5d-6) + late final _layout = ExplorerLayout(_ctl, context: () => context); - /// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다 - Future _reconnect(ExplorerPane pane, String path) async { - await _probeDrive(path); - if (!mounted) return; - if (_ctl.drives.ok[path] == true) { - pane.cache.remove(path); - await _ctl.goTo(pane, path); - } else { - _snack(_ctl.drives.removable.contains(path) - ? trf('{0} 이 비어 있습니다 (카드 · USB · 디스크를 넣은 뒤 다시 누르세요).', [path]) - : trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path])); - } - } - - /// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다 - final _keys = FocusNode(debugLabel: 'explorer'); + /// 키보드 · 마우스 (E5d-6) + late final _input = ExplorerInput(_ctl, _sel, _flows, _buttons, dual: () => _layout.dual); @override void initState() { @@ -226,18 +181,17 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex @override void dispose() { c.removeListener(_onSettings); - _keys.dispose(); + _input.dispose(); if (widget.rsync) { ExplorerPage._openRsync--; } else { ExplorerPage._open--; ExplorerPage.goToRequest.removeListener(_onGoToRequest); } - _transfers.dispose(); - _jobs.dispose(); + _flows.dispose(); _sel.dispose(); _ctl.dispose(); - if (_allFiles != null) _life.dispose(); + _access.dispose(); _resumeReload.dispose(); super.dispose(); } @@ -250,8 +204,8 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex _ctl.local = ExplorerPage.debugVolumes?.call() ?? await localVolumes(); if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; // X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서) - if (_deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0)); - if (Platform.isAndroid) unawaited(_checkAccess()); + if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0)); + if (Platform.isAndroid) unawaited(_access.checkAccess()); final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths; // 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게 // 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유) @@ -283,451 +237,22 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex final center = CopyCenter.peekOf(c); if (center != null) { for (final job in center.jobs.values.where((j) => !j.finished)) { - _jobs.adopt(job); + _flows.jobs.adopt(job); } } // 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로) if (Platform.isWindows) { _ctl.drives.classify([for (final (path, _) in _ctl.local) path]); for (final (path, _) in _ctl.local) { - unawaited(_probeDrive(path)); + unawaited(_access.probeDrive(path)); } } for (var i = 0; i < 2; i++) { final pane = _ctl.panes[i]; - unawaited(_ctl.goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current))); + unawaited(_ctl.goTo(pane, wants[i], remember: false).then((_) => _layout.reveal(pane, pane.current))); } } - // ───────── 권한 (Android) ───────── - - /// "모든 파일에 대한 접근" 권한 (없으면 폴더만 보이고 파일은 안 보인다). null = 아직 모름 - bool? _allFiles; - - /// 듀얼 앱 (복제한 앱) 인지: 저장소가 /storage/emulated/0 이 아님 - bool _dualApp = false; - - late final AppLifecycleListener _life = AppLifecycleListener(onResume: () { - if (Platform.isAndroid && _allFiles == false) unawaited(_checkAccess(reload: true)); - }); - - Future _checkAccess({bool reload = false}) async { - _life; // 다시 돌아오면 (권한 화면에서 허용하고 오면) 다시 확인 - final ok = await AndroidAccess.hasAllFiles(); - var dual = false; - try { - final root = await AndroidAccess.storageRoot(); - dual = root.contains('/emulated/') && !root.endsWith('/emulated/0'); - } catch (_) {} - if (!mounted) return; - final changed = ok != _allFiles; - setState(() { - _allFiles = ok; - _dualApp = dual; - }); - if (reload && changed && ok) { - for (final pane in _ctl.panes) { - pane.cache.clear(); - for (final d in pane.expanded.toList()) { - await _ctl.load(pane, d, force: true); - } - } - } - } - - /// 권한이 없을 때 목록 위 안내 (이유 · 할 일 · 허용 화면 열기 · 다시 확인) - /// X-plore 비교 5-2: 시스템 폴더를 보는 동안 늘 주황 띠 - Widget? _shizukuNotice(ExplorerPane pane) { - if (!isShizuku(pane.current)) return null; - final root = isRootPath(pane.current); - return _notice( - icon: root ? Icons.security : Icons.admin_panel_settings_outlined, - title: root ? tr('루트 (su) - 읽기 전용') : tr('시스템 폴더 (Shizuku) - 읽기 전용'), - body: root - ? tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 시스템 · 다른 앱의 개인 자료가 있으니 꺼낸 파일을 조심해서 다루세요') - : tr('목록 · 보기 · 꺼내기 (복사) 만 됩니다. 다른 앱의 데이터가 있을 수 있으니 꺼낸 파일을 조심해서 다루세요'), - actions: const [], - color: JjColors.warning, - ); - } - - /// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정) - Future _openRoot(ExplorerPane pane) async { - if (!ExplorerPage.rootWarnedThisRun) { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - icon: Icon(Icons.security, color: JjColors.warning), - title: Text(tr('루트 폴더 보기 (읽기 전용)')), - content: Text(tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 ' - '보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))), - ], - ), - ); - if (ok != true || !mounted) return; - ExplorerPage.rootWarnedThisRun = true; - } - try { - final st = await ShizukuRemote.rootBackend.status(); - if (!st.running) { - _snack(tr('이 기기에는 su 가 없습니다 - 루팅한 기기에서만 쓸 수 있습니다')); - return; - } - if (!st.granted && !await ShizukuRemote.rootBackend.request()) { - _snack(tr('루트 허락이 없어 볼 수 없습니다 (su 앱에서 이 앱을 허락하세요)')); - return; - } - } catch (e) { - _snack(trf('루트에 연결하지 못했습니다: {0}', [e])); - return; - } - if (!mounted) return; - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - await _ctl.goTo(pane, rootHome); - } - - /// X-plore 비교 5-2: 시스템 폴더 탭 - 이번 실행에서 처음이면 경고, Shizuku 가 켜져 있고 허락을 받았을 때만 Android 폴더로 - Future _openShizuku(ExplorerPane pane) async { - if (!ExplorerPage.shizukuWarnedThisRun) { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - icon: Icon(Icons.admin_panel_settings_outlined, color: JjColors.warning), - title: Text(tr('시스템 폴더 보기 (읽기 전용)')), - content: Text(tr('Shizuku 의 권한으로 Android/data · obb 처럼 막힌 폴더를 봅니다. 다른 앱의 데이터 (대화 · 로그인 정보 등) 가 ' - '있을 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))), - ], - ), - ); - if (ok != true || !mounted) return; - ExplorerPage.shizukuWarnedThisRun = true; - } - try { - final st = await ShizukuRemote.backend.status(); - if (!st.running) { - _snack(tr('Shizuku 가 켜져 있지 않습니다 - Shizuku 앱을 설치하고 (무선 디버깅 · adb 로) 시작한 뒤 다시 누르세요')); - return; - } - if (!st.granted && !await ShizukuRemote.backend.request()) { - _snack(tr('Shizuku 허락이 없어 볼 수 없습니다')); - return; - } - } catch (e) { - _snack(trf('Shizuku 에 연결하지 못했습니다: {0}', [e])); - return; - } - if (!mounted) return; - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - await _ctl.goTo(pane, shizukuHome(_ctl.local.first.$1)); - } - - Widget? _accessNotice(ExplorerPane pane) { - if (!Platform.isAndroid || _allFiles != false || isDav(pane.root)) return null; - return _notice( - icon: Icons.lock_outline, - title: tr('파일이 보이지 않습니다: "모든 파일에 대한 접근" 권한이 없습니다'), - body: _dualApp - ? tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. ' - '허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.') - : tr('폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.'), - actions: [ - FilledButton.tonal(onPressed: AndroidAccess.request, child: Text(tr('권한 허용 화면 열기'))), - TextButton(onPressed: () => _checkAccess(reload: true), child: Text(tr('허용했으면 다시 확인'))), - ], - ); - } - - /// 목록 위 안내 상자 - Widget _notice( - {required IconData icon, - required String title, - required String body, - required List actions, - Color? color}) => - _noticeBox(icon, title, body, actions, color ?? JjColors.alert); - - Widget _noticeBox(IconData icon, String title, String body, List actions, Color color) => - Container( - width: double.infinity, - margin: const EdgeInsets.fromLTRB(6, 6, 6, 0), - padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.5)), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 18, color: color), - const SizedBox(width: 8), - Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))), - ]), - const SizedBox(height: 4), - Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)), - Wrap(spacing: 8, children: actions), - ]), - ); - - // ───────── 만들다 만 파일 (71) ───────── - - /// 복사 · 동기화가 중간에 끊겨 남은 임시 파일 (.jjpart · .jjsync). 지금 쓰는 중인 것 (2분 안에 바뀜) 은 빼고 - List _partials(ExplorerPane pane, String dir) { - final old = DateTime.now().subtract(const Duration(minutes: 2)); - return [ - for (final e in pane.cache[dir] ?? const []) - if (!e.isDir && isPartialFile(e.name) && e.modified.isBefore(old)) e, - ]; - } - - Widget? _partialNotice(ExplorerPane pane, String dir) { - final list = _partials(pane, dir); - if (list.isEmpty) return null; - return _notice( - icon: Icons.broken_image_outlined, - color: JjColors.warning, - title: trf('만들다 만 파일 {0}개', [list.length]), - body: tr('복사 · 동기화가 중간에 끊겨 남은 임시 파일입니다 (.jjpart). 완성된 파일이 아니므로 지워도 됩니다. ' - '같은 파일을 다시 복사하면 새로 만듭니다.'), - actions: [ - TextButton( - onPressed: () async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(trf('만들다 만 파일 {0}개를 지울까요?', [list.length])), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final e in list.take(50)) Text(e.name, style: const TextStyle(fontSize: 12)), - if (list.length > 50) Text(trf('… 외 {0}개', [list.length - 50])), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), - ], - ), - ); - if (ok != true) return; - final failed = []; - for (final e in list) { - try { - await vDelete(e.path); - } catch (_) { - failed.add(e.name); - } - } - // 같은 폴더를 보는 다른 창도 새로 - for (final q in _ctl.panes) { - if (q == pane || q.cache.containsKey(dir)) await _ctl.load(q, dir, force: true); - } - if (failed.isNotEmpty) _snack(trf('지우지 못한 파일: {0}', [failed.join(', ')])); - }, - child: Text(tr('지우기')), - ), - ], - ); - } - - // ───────── 읽을 수 없을 때 (12) ───────── - - /// 오류 → (무엇이 문제인지, 무엇을 하면 되는지) - Widget? _errorNotice(ExplorerPane pane, String dir) { - final err = pane.errors[dir] ?? pane.errors[pane.root]; - if (err == null) return null; - final where = pane.errors[dir] != null ? dir : pane.root; - final dav = isDav(where); - final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null; - final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null; - final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null; - final ftp = isFtp(where) ? FtpRegistry.server(DavPath.parse(where).server) : null; - // l: 처음 보는 FTPS 자체 서명 인증서 - 지문을 보이고 [지문 확인하고 연결] (바뀐 인증서는 막고 서버 설정으로) - final cert = pane.certErrors[where] ?? pane.certErrors[pane.root]; - if (ftp != null && cert != null) { - return _notice( - icon: cert.changed ? Icons.gpp_bad_outlined : Icons.fingerprint, - title: '${_ctl.displayPath(where)}: ${cert.changed ? tr('서버 인증서가 바뀌었습니다') : tr('처음 보는 서버 인증서입니다')}', - body: cert.message, - actions: [ - if (!cert.changed) - FilledButton.tonal( - onPressed: () async { - if (!await askTrustFtpCert(context, ftp.label, cert)) return; - await trustFtpCert(c, ftp.id, cert); - pane.certErrors.remove(where); - pane.errors.remove(where); - pane.changed(); - await _ctl.load(pane, where, force: true); - }, - child: Text(tr('지문 확인하고 연결')), - ), - TextButton(onPressed: () => _servers.editFtp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))), - ], - ); - } - // 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로) - final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root]; - if (sftp != null && hostKey != null) { - return _notice( - icon: hostKey.changed ? Icons.gpp_bad_outlined : Icons.fingerprint, - title: '${_ctl.displayPath(where)}: ${hostKey.changed ? tr('서버의 키가 바뀌었습니다') : tr('처음 연결하는 서버입니다')}', - body: hostKey.message, - actions: [ - if (!hostKey.changed) - FilledButton.tonal( - onPressed: () async { - if (!await askTrustHostKey(context, sftp.label, hostKey)) return; - await trustSftpHostKey(c, sftp.id, hostKey); - pane.hostKeyErrors.remove(where); - pane.errors.remove(where); - pane.changed(); - await _ctl.load(pane, where, force: true); - }, - child: Text(tr('지문 확인하고 연결')), - ), - TextButton(onPressed: () => _servers.editSftp(context, pane, pane.root), child: Text(tr('서버 설정 고치기'))), - ], - ); - } - final (title, body) = explainFileError(err, - dav: dav, - noPassword: (server != null && server.password.isEmpty) || - (sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) || - (smb != null && smb.user.isNotEmpty && smb.password.isEmpty) || - (ftp != null && ftp.user.isNotEmpty && ftp.password.isEmpty)); - final locked = isLockedError(err); - // 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로] - if (err == androidRestrictedError) { - return _notice( - icon: Icons.lock_outline, - title: '${_ctl.displayPath(where)}: $title', - body: body, - actions: [ - FilledButton.tonal( - onPressed: () { - pane.errors.remove(where); - _ctl.goTo(pane, vDirname(where)); - }, - child: Text(tr('위 폴더로')), - ), - ], - ); - } - return _notice( - icon: dav ? Icons.cloud_off_outlined : Icons.error_outline, - title: '${_ctl.displayPath(where)}: $title', - body: body, - actions: [ - FilledButton.tonal( - onPressed: () async { - // 133: 마스터를 취소해 막혔으면 여기서 다시 묻는다 - if (locked && !await SecretGate.pass(force: true)) return; - pane.errors.remove(where); - pane.changed(); - await _ctl.load(pane, where, force: true); - for (final d in pane.expanded.toList()) { - if (isSameOrInside(d, where) && d != where) await _ctl.load(pane, d, force: true); - } - }, - child: Text(locked ? tr('마스터 비밀번호 넣기') : tr('다시 시도')), - ), - // 시스템 폴더 (Shizuku) 는 고칠 서버 설정이 없다 - if (dav && !locked && !isShizuku(where)) - TextButton( - onPressed: () => sftp != null - ? _servers.editSftp(context, pane, pane.root) - : smb != null - ? _servers.editSmb(context, pane, pane.root) - : ftp != null - ? _servers.editFtp(context, pane, pane.root) - : _servers.editDav(context, pane, pane.root), - child: Text(tr('서버 설정 고치기'))), - ], - ); - } - - /// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로 - /// 그 항목이 목록 위쪽 1/4 쯤에 보이게 스크롤 (깊은 폴더로 가면 위쪽 폴더들의 다른 항목에 밀려 안 보이므로) - void _reveal(ExplorerPane pane, String path) => WidgetsBinding.instance.addPostFrameCallback((_) => _revealNow(pane, path)); - - void _revealNow(ExplorerPane pane, String path) { - if (!mounted || !pane.scroll.hasClients) return; - // 화면의 트리와 같은 줄로 센다 (폴더 + 파일 목록 배치 · Rsync 화면은 폴더만) - final i = _ctl.rows(pane, foldersOnly: _split || widget.rsync).indexWhere((r) => r.kind == 0 && samePath(r.entry.path, path)); - if (i < 0) return; - final pos = pane.scroll.position; - // 180: 끝 (maxScrollExtent) 으로 자르지 않는다 - 줄이 막 늘어난 때는 끝이 아직 앞의 것이라 보던 폴더로 못 간다. - // 넘치면 다음 배치 때 실제 끝으로 맞춰진다 - pane.scroll.jumpTo((i * _rowH() - pos.viewportDimension / 4).clamp(0.0, double.infinity)); - } - - /// 28 확인 (10/20): 창의 목록이 화면에 새로 붙으면 (좁은 Rsync 화면의 [오른쪽] 탭을 처음 볼 때 · 폭이 바뀌어 두 창이 될 때) - /// 그 창의 지금 폴더를 보이게 한다. 켤 때 그려지지 않은 창은 [_ctl.goTo] 의 스크롤을 건너뛰어, 나중에 보면 맨 위 (저장 장치) 에 있었다 - void _revealNewViews() => WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - for (final pane in _ctl.panes) { - final on = pane.scroll.hasClients; - if (on && !pane.onScreen && pane.current.isNotEmpty) _revealNow(pane, pane.current); - pane.onScreen = on; - } - }); - - /// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이 - double get _listTextScale => c.settings.explorerTextScale / 100; - double _rowH() => _ctl.look.rowHeightOf(context, textScale: _listTextScale, spacing: rowSpacingFactor(c.settings.explorerRowSpacing)); - - /// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치 - bool get _split => !widget.rsync && c.settings.explorerLayout == 'split' && !_splitTight; - - /// 관리자 지시 10/20 (28 검토): 글자가 커서 (기기 글자 × 목록 글자 크기) 좁은 화면에 트리 · 목록 두 창이 다 들어가지 않으면 - /// 그동안만 한 창 (트리 + 위 경로 막대) 으로 보인다 - 고른 배치 (설정) 는 그대로, 화면이 넓어지거나 글자가 작아지면 다시 두 창. [_body] 가 정한다 - bool _splitTight = false; - - /// 관리자 지시 10/20: 좁은 화면 (폭 < [narrowSplitWidth]) 의 "폴더 + 파일 목록" 좌우 배치 - 트리 창에 최소 폭을 두고 - /// ✔ 칸 · 크기 줄을 숨긴다 (412 폭에서 트리가 77px 이라 폴더 이름이 한 글자도 안 보였다). [_body] 가 정한다 - bool _narrowSplit = false; - static const narrowSplitWidth = 600.0; - - /// 좌우 두 창 (Rsync 화면은 늘) - bool get _dual => - widget.rsync || - c.settings.explorerLayout == 'dual' || - // 자동: 넓으면 두 창, 좁은 화면 (폰 세로) 은 한 창 - 같은 목록이 반씩 잘려 두 번 보이지 않게 - (c.settings.explorerLayout == 'auto' && !isCompact(context)); - - - /// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽) - /// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정) - /// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정) - bool get _alwaysMarks => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerAlwaysMarks; - - /// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정) - bool get _swipePanes => !widget.rsync && _ctl.look == ExplorerStyle.xplore && c.settings.explorerSwipePanes; - - /// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다 - bool _switchPane(int dir) { - if (!_swipePanes) return false; - setState(() { - if (_dual || _split) { - _ctl.active = 1 - _ctl.active; - } else { - final t = _ctl.panes[0]; - _ctl.panes[0] = _ctl.panes[1]; - _ctl.panes[1] = t; - _ctl.active = 0; - } - }); - _ctl.remember(_ctl.pane, _ctl.pane.current); - _snack(trf('창 바꿈: {0}', [_ctl.displayPath(_ctl.pane.current)])); - return true; - } - /// 설정 화면에서 숨긴 목록을 바꾸면 (풀기) 바로 목록에 보이게 List? _hiddenSeen; /// 종류별 기본 정렬 (5-1 ①) 을 켜고 끈 것 - 바뀌면 읽어 둔 목록을 다시 줄 세운다 @@ -756,671 +281,92 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex // ───────── 창 보기 (E5d-3: ExplorerPaneView · ExplorerListView 가 부르는 것) ───────── @override - double get rowHeight => _rowH(); + double get rowHeight => _layout.rowHeight(); @override - double get listTextScale => _listTextScale; + double get listTextScale => _layout.listTextScale; @override - bool get narrowSplit => _narrowSplit; + bool get narrowSplit => _layout.narrowSplit; @override - bool get alwaysMarks => _alwaysMarks; + bool get alwaysMarks => _layout.alwaysMarks; @override - Widget frame(int i, List children) => _frame(i, children); + Widget frame(int i, List children) => + ExplorerPaneFrame(ctl: _ctl, index: i, onPointer: _input.mouseTravel, onDrop: onDrop, children: children); @override Widget header(ExplorerPane pane) => ExplorerPaneHeader( ctl: _ctl, servers: _servers, pane: pane, android: ExplorerPage.debugAndroidPaths ?? Platform.isAndroid, - onBookmarks: () => openBookmarks(pane), - onOpenFilter: () => _openFilter(pane), - onCloseFilter: () => _closeFilter(pane), - onOpenRoot: () => _openRoot(pane), - onOpenShizuku: () => _openShizuku(pane), - onReconnect: (path) => _reconnect(pane, path), + onBookmarks: () => _flows.dialogs.openBookmarks(context, pane), + onOpenFilter: () => _input.openFilter(pane), + onCloseFilter: () => _input.closeFilter(pane), + onOpenRoot: () => _access.openRoot(context, pane), + onOpenShizuku: () => _access.openShizuku(context, pane), + onReconnect: (path) => _access.reconnect(pane, path), ); @override - List notices(ExplorerPane pane, String dir) => - [_shizukuNotice(pane), _accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)]; + List notices(ExplorerPane pane, String dir) => _notices.of(context, pane, dir); @override Widget marksBar(ExplorerPane pane, {String? listDir}) => ExplorerMarksBar(sel: _sel, pane: pane, listDir: listDir); @override void onMenu(ExplorerPane pane, FileEntry e, Offset at) => _itemMenu.show(context, pane, e, at); @override - List dragPaths(ExplorerPane pane, FileEntry e) => _dragPaths(pane, e); + List dragPaths(ExplorerPane pane, FileEntry e) => _flows.drops.dragPaths(pane, e); @override - Future onDrop(DragLoad load, int to, String dest) => _onDrop(load, to, dest); + Future onDrop(DragLoad load, int to, String dest) => _flows.drops.drop(context, load, to, dest); @override - void closeFilter(ExplorerPane pane) => _closeFilter(pane); + void closeFilter(ExplorerPane pane) => _input.closeFilter(pane); @override void filterChanged() => setState(() {}); /// 기능 버튼 → 할 일 (E5d-4) - late final _buttons = ExplorerButtons(_ctl, _sel, this, guard: _guard, transfers: _transfers, deletes: _deletes, rsync: _rsyncFlow, opener: _opener); + late final _buttons = ExplorerButtons(_ctl, _sel, this, _flows); - /// 읽기 전용인 곳 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 (E5d-5) - late final _guard = ExplorerGuard(_ctl); - late final _jobs = ExplorerJobs(_ctl)..addListener(_onPane); - late final _transfers = ExplorerTransfers(_ctl, _guard, _jobs)..addListener(_onPane); - late final _rsyncFlow = ExplorerRsyncFlow(_ctl, _jobs, _transfers); - late final _deletes = ExplorerDeleteFlow(_ctl, _sel, _guard, androidTrash: () => ExplorerPage.debugAndroidTrash ?? Platform.isAndroid); + /// 흐름 모듈 묶음 - 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 · 열기 · 이름 · 도구 · 보기 창 · 찾기 (E5d-5 · E5d-6) + late final ExplorerFlows _flows = ExplorerFlows(_ctl, _sel, + androidTrash: () => ExplorerPage.debugAndroidTrash ?? Platform.isAndroid, + otherDirOf: (pane) => _layout.dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null, + otherDir: () => _layout.other?.current, + twoPanes: () => _layout.dual || _layout.split) + ..jobs.addListener(_onPane) + ..transfers.addListener(_onPane) + ..search.addListener(_onPane); @override - String? get otherDir => _other?.current; + String? get otherDir => _layout.other?.current; /// 시험: [휴지통] 열기 (E5e 에서 지움) @visibleForTesting - Future debugOpenTrash() => _deletes.openTrash(context); + Future debugOpenTrash() => _flows.deletes.openTrash(context); /// 줄 메뉴 (E5d-5) - late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this, - guard: _guard, transfers: _transfers, deletes: _deletes, opener: _opener, nomedia: () => ExplorerPage.debugNomedia ?? Platform.isAndroid); + late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this, _flows, nomedia: () => ExplorerPage.debugNomedia ?? Platform.isAndroid); @override - bool get dual => _dual; + bool get dual => _layout.dual; - /// 파일 열기 · 보기 · 재생 (E5d-5) - late final ExplorerOpener _opener = ExplorerOpener(_ctl, _sel, _guard, - otherDirOf: (pane) => _dual ? _ctl.panes[1 - _ctl.panes.indexOf(pane)].current : null); + /// 권한 · 시스템 폴더 · 다시 연결 · 목록 위 안내 (E5d-6) + late final _access = ExplorerAccess(_ctl, probe: (path) => ExplorerPage.probeDrive(path))..addListener(_onPane); + late final _notices = ExplorerNotices(_ctl, _access, _servers); /// 서버 추가 · 고치기 · 경로 입력 (E5d-3) late final _servers = ExplorerServers(_ctl); // ───────── 누르기 · 고르기 (E5d-2: ExplorerSelect) ───────── - late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _opener.open(context, path, pane: pane)) + late final _sel = ExplorerSelect(_ctl, clock: widget.clock, openFile: (path, pane) => _flows.opener.open(context, path, pane: pane)) ..addListener(_onPane); - /// ZIP: 환경 설정이 "만화 보기" 면 안의 그림을 바로 보고 (그림이 없으면 목록), 아니면 목록 - /// 87: 저장 공간 분석 → 고른 것을 탐색기에서 그 자리로 (지우기는 탐색기의 지우기로만). - /// WebDAV 는 직접 시킬 때만 (이 메뉴) 하고, 서버에서 모두 읽어 오래 걸린다고 먼저 알린다 - @override - Future openStorage(ExplorerPane pane, String dir) async { - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - if (isDav(dir)) { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('저장 공간 분석')), - content: Text(tr('WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('분석'))), - ], - ), - ); - if (ok != true || !mounted) return; - } - // X-plore 비교 5-1 ⑤ (관리자 지시 10/21): 분석 화면에서도 이 탐색기의 지우기 (확인 · 휴지통) · 이동 (붙여넣기로) - final path = await StorageAnalysisPage.open(context, dir, onDelete: (paths) async { - await _deletes.run(context, pane, paths); - return [for (final x in paths) if (!await vExists(x)) x]; - }, onCut: (paths) => _transfers.toClipboard(paths, move: true)); - if (path == null || !mounted) return; - final isDir = await vIsDir(path); - await _ctl.goTo(pane, isDir ? path : vDirname(path), fresh: true); - pane.focused = path; - pane.changed(); - _reveal(pane, path); - } - - /// 89: Wi-Fi 공유 화면 (이미 켜져 있으면 그 공유를 보인다). 이 기기의 폴더만 - WebDAV 등 원격은 알림 - @override - Future openWifiShare(String dir) async { - final on = WifiShare.instance; - if (on == null && isDav(dir)) { - _snack(tr('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다')); - return; - } - await WifiSharePage.open(context, c, on?.root ?? dir); - } - - /// 89: 탐색기 위의 [Wi-Fi 공유 중] 줄 - 화면을 닫아도 켜져 있음을 늘 보이게 (사용자가 모르게 열려 있지 않게) - Widget _wifiBar(WifiShare s) { - final left = s.endsAt?.difference(DateTime.now()); - return Material( - color: JjColors.panelHigh, - child: InkWell( - onTap: () => WifiSharePage.open(context, c, s.root), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row(children: [ - Icon(Icons.wifi_tethering, size: 18, color: JjColors.success), - const SizedBox(width: 6), - Expanded( - child: Text( - left == null - ? trf('Wi-Fi 공유 중 · 코드 {0} · {1}', [s.code, s.root]) - : trf('Wi-Fi 공유 중 · 코드 {0} · {1}분 남음 · {2}', [s.code, (left.inSeconds / 60).ceil().clamp(0, 9999), s.root]), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12)), - ), - TextButton(onPressed: () => s.stop(), child: Text(tr('끄기'))), - ]), - ), - ), - ); - } - - // ───────── 기능 ───────── - - ExplorerPane? get _other => _dual || _hiddenTarget ? _ctl.panes[1 - _ctl.active] : null; - - // ───────── X-plore 비교 f: 압축을 트리 안에서 폴더처럼 (ZIP · 7z · RAR, 읽기 전용) ───────── - - /// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄) - bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other'; - - /// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말) - String _folderName(String dir) => - _ctl.volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _ctl.displayPath(dir) : vBasename(dir))).$2; - - /// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더] - Widget _targetBar() { - final t = _ctl.panes[1 - _ctl.active]; - return Material( - color: JjColors.accent.withValues(alpha: 0.10), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: Row(children: [ - Icon(Icons.arrow_forward, size: 18, color: JjColors.accent), - const SizedBox(width: 6), - // 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려) - Expanded( - child: Tooltip( - message: _ctl.displayPath(t.current), - triggerMode: TooltipTriggerMode.longPress, - child: LayoutBuilder(builder: (context, box) { - const style = TextStyle(fontSize: 12.5); - final label = trf('갈 곳: {0}', ['']); - final room = box.maxWidth - textWidth(context, label, style); - return Text('$label${middleEllipsis(context, _folderName(t.current), style, room)}', maxLines: 1, overflow: TextOverflow.clip, style: style); - }), - ), - ), - TextButton( - style: TextButton.styleFrom(visualDensity: VisualDensity.compact), - onPressed: () => _ctl.goTo(t, _ctl.pane.current), - child: Text(tr('지금 폴더로')), - ), - TextButton( - style: TextButton.styleFrom(visualDensity: VisualDensity.compact), - onPressed: () async { - final d = await pickFolderOrDav(context, tr('복사 · 이동 갈 곳'), t.current); - if (d != null && mounted) await _ctl.goTo(t, d); - }, - child: Text(tr('다른 폴더')), - ), - ]), - ), - ); - } - void _snack(String text) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } - @override - Future addMkv(List paths) async { - if (paths.any(isDav)) { - _snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.')); - return; - } - final videos = []; - final unreadable = []; - for (final x in paths) { - if (vIsDirSync(x)) { - try { - videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path)); - } catch (_) { - unreadable.add(vBasename(x)); // 읽지 못한 폴더는 건너뛰고 알린다 - } - } else if (isVideoFile(x)) { - videos.add(x); - } - } - if (unreadable.isNotEmpty) _snack(trf('읽을 수 없어 건너뛴 폴더: {0}', [unreadable.take(3).join(', ')])); - if (videos.isEmpty) { - if (unreadable.isEmpty) _snack(tr('추가할 동영상이 없습니다.')); - return; - } - await c.addVideos(videos, allowOutputFolder: true); - _snack(trf('MKV 목록에 {0}개를 추가했습니다.', [videos.length])); - } - - @override - Future renameItem(ExplorerPane pane, String path) async { - if (_guard.blockArchive([path])) return; - final name = await askName(context, tr('이름 변경'), vBasename(path)); - if (name == null || name == vBasename(path)) return; - try { - final now = await FileOps.rename(path, name); - await _ctl.followHidden(moved: [(path, now)]); - await _ctl.refreshAll([vDirname(path)]); - for (final x in _ctl.panes) { - x.sel.rename(path, now); - } - pane.focused = now; - pane.changed(); - } catch (e) { - _snack(trf('이름을 바꿀 수 없습니다: {0}', [e])); - } - } - - /// 78: 일괄 이름 바꾸기 - 번호는 목록에 보이는 차례대로. 끝나면 [되돌리기] - @override - Future batchRename(ExplorerPane pane, List paths) async { - if (_guard.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; - } - - final ordered = [...paths]..sort((a, b) { - final d = vDirname(a).compareTo(vDirname(b)); - return d != 0 ? d : at(a).compareTo(at(b)); - }); - final done = await showBatchRename(context, ordered, - isDir: vIsDirSync, - initial: RenameRule.fromJson(c.settings.batchRenameLast), - onRun: (r) => c.updateSettings((s) => s.batchRenameLast = r.toJson())); - if (done == null || done.isEmpty || !mounted) return; - await _afterBatchRename(pane, done); - if (!mounted) return; - showNotice(ScaffoldMessenger.of(context), - text: trf('{0}개의 이름을 바꿨습니다.', [done.length]), - action: ( - tr('되돌리기'), - () async { - try { - final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]); - if (mounted) await _afterBatchRename(pane, back); - } on BatchRenameException catch (e) { - _snack(trf('되돌리지 못했습니다: {0}', [e])); - } - } - )); - } - - /// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로 - Future _afterBatchRename(ExplorerPane pane, List<(String, String)> moved) async { - await _ctl.followHidden(moved: moved); - await _ctl.refreshAll({for (final (a, _) in moved) vDirname(a)}); - for (final x in _ctl.panes) { - for (final (from, to) in moved) { - x.sel.rename(from, to); - if (x.focused != null && samePath(x.focused!, from)) x.focused = to; - } - x.changed(); - } - } - void _onGoToRequest() { final dir = ExplorerPage.goToRequest.value; if (dir == null || !_ctl.ready || !mounted) return; ExplorerPage.goToRequest.value = null; - unawaited(_ctl.goTo(_ctl.pane, dir, fresh: true).then((_) => _reveal(_ctl.pane, dir))); - } - - /// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값) - SearchConditions _lastSearch = const SearchConditions(); - - /// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐) - ({ExplorerPane pane, String root, SearchConditions cond, List found, bool done, Map moved})? _searchBack; - - /// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생) - @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; - _lastSearch = cond; - setState(() => _searchBack = null); - await _searchPage(pane, pane.current, cond); - } - - /// 79-a: [찾기 결과로 돌아가기] - 다시 찾지 않고, 그사이 지운 것은 빼고 옮긴 것은 새 자리로 - Future _backToSearch() async { - final back = _searchBack; - if (back == null) return; - final now = []; - for (final e in back.found) { - if (await vExists(e.path)) { - now.add(e); - continue; - } - final dest = back.moved[e.path]; - final there = dest == null ? null : vJoin(dest, vBasename(e.path)); - if (there != null && await vExists(there)) { - now.add(FileEntry(there, isDir: e.isDir, size: e.size, modified: e.modified)); - } - // 고른 폴더와 함께 옮겨진 안쪽 것: 옮긴 폴더 아래 같은 자리 - else { - for (final entry in back.moved.entries) { - if (!isSameOrInside(e.path, entry.key)) continue; - // 옮긴 폴더 이름 아래 남은 경로 (같은 꼴의 경로라 앞부분을 잘라 쓴다 - WebDAV 도) - final rest = e.path.length > entry.key.length ? e.path.substring(entry.key.length + 1) : ''; - final moved = rest.isEmpty ? vJoin(entry.value, vBasename(entry.key)) : vJoin(vJoin(entry.value, vBasename(entry.key)), rest); - if (await vExists(moved)) now.add(FileEntry(moved, isDir: e.isDir, size: e.size, modified: e.modified)); - break; - } - } - } - if (!mounted) return; - await _searchPage(back.pane, back.root, back.cond, initial: now, initialDone: back.done); - } - - Future _searchPage(ExplorerPane pane, String root, SearchConditions cond, {List? initial, bool initialDone = true}) async { - final other = _other?.current; - final act = await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => SearchResultsPage( - root: root, - cond: cond, - showHidden: c.settings.explorerShowHidden, - otherDir: other, - onOpen: (e) => _opener.open(context, e.path, pane: pane), - initial: initial, - initialDone: initialDone, - ), - )); - if (!mounted) return; - // 결과 화면을 그냥 닫으면 돌아갈 결과도 없앤다 - if (act == null || act.paths.isEmpty) { - setState(() => _searchBack = null); - return; - } - // 관리자 판단 (10/17): [그 자리로] 로 나가도 남긴다 - 한 곳을 확인하고 다음 결과로 가는 것이 가장 흔한 쓰임 - { - setState(() => _searchBack = ( - pane: pane, - root: root, - cond: cond, - found: act.found, - done: act.done, - moved: act.kind == 'move' && other != null ? {for (final x in act.paths) x: other} : const {}, - )); - } - switch (act.kind) { - case 'reveal': - final path = act.paths.first; - final dir = await vIsDir(path); - await _ctl.goTo(pane, dir ? path : vDirname(path), fresh: true); - pane.focused = path; - pane.changed(); - _reveal(pane, path); - // 고른 것만 대상 (154 · 155) - 탐색기의 복사 · 이동 · 지우기와 같은 확인 · 진행 패널 · 휴지통 - case 'copy': - await _transfers.transfer(context, pane, act.paths, move: false, otherDir: otherDir); - case 'move': - await _transfers.transfer(context, pane, act.paths, move: true, otherDir: otherDir); - case 'delete': - await _deletes.run(context, pane, act.paths); - } - } - - /// 79-a: 탐색기 위의 [찾기 결과로 돌아가기] 줄 - Widget _searchBackBar() { - final back = _searchBack!; - return Material( - color: JjColors.panelHigh, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row(children: [ - Icon(Icons.manage_search, size: 18, color: JjColors.accent), - const SizedBox(width: 6), - Expanded( - child: Text(trf('찾기 결과 ({0}) · {1}', [back.cond.summary, back.root]), - maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - TextButton(onPressed: _backToSearch, child: Text(tr('찾기 결과로 돌아가기'))), - IconButton( - tooltip: tr('찾기 결과 닫기'), - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.close, size: 16), - onPressed: () => setState(() => _searchBack = null), - ), - ]), - ), - ); - } - - /// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 알림. [다음부터 묻지 않기] 는 환경 설정에서 다시 켬 - @override - Future sortDialog() async { - // 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬 - final dir = _ctl.pane.current; - var (by, desc) = _ctl.sortOf(dir); - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - scrollable: true, - title: Text(tr('정렬 기준')), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Wrap(spacing: 6, children: [ - for (final (v, label) in [ - ('name', tr('이름')), - ('date', tr('바뀐 날')), - ('size', tr('크기')), - ('type', tr('종류 (확장자)')), - ]) - ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)), - ]), - SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)), - if (c.settings.explorerSortPerFolder) - Text(trf('이 폴더에만: {0}', [_ctl.displayPath(dir)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), - ], - ), - ), - ); - if (ok != true) return; - await _ctl.setSort(dir, by, desc); - } - - /// 73: 즐겨찾기 - 이 창으로 가기, Rsync 화면이면 짝 (왼쪽 · 오른쪽) 을 한 번에 - @override - Future openBookmarks(ExplorerPane pane) async { - setState(() => _ctl.active = _ctl.panes.indexOf(pane)); - await showFolderBookmarks( - context, - c, - current: pane.current, - right: widget.rsync ? _ctl.panes[1].current : null, - go: (dir) => _ctl.goTo(pane, dir, fresh: true), - goPair: widget.rsync - ? (a, b) { - _ctl.goTo(_ctl.panes[0], a, fresh: true); - _ctl.goTo(_ctl.panes[1], b, fresh: true); - } - : null, - ); - if (mounted) setState(() {}); - } - - @override - Future historyDialog(ExplorerPane pane) async { - final hist = c.settings.explorerHistory.where(reachable).toList(); - final pick = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: Text(tr('내역 (최근 폴더)')), - children: [ - if (hist.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('없음'))), - for (final h in hist) - SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, h), - child: Text(h, maxLines: 2, overflow: TextOverflow.ellipsis), - ), - ], - ), - ); - if (pick != null) await _ctl.goTo(pane, pick, fresh: true); - } - - /// 118: 두 창이면 좌우 ⇆ 위아래 를 바꾸고, 한 창이면 두 창 (좌우) 으로. 한 번 눌러 바로 - @override - Future toggleOrientation() async { - final s = c.settings; - if (!(_dual || _split)) { - await c.updateSettings((x) => x - ..explorerLayout = 'dual' - ..explorerOrientation = 'side'); - } else { - // 지금 보이는 배치의 반대 ('화면 모양 따라' 였으면 지금 모양에서 정함) - final size = MediaQuery.sizeOf(context); - final nowSide = switch (s.explorerOrientation) { - 'side' => true, - 'stacked' => false, - _ => size.width >= size.height, - }; - await c.updateSettings((x) => x.explorerOrientation = nowSide ? 'stacked' : 'side'); - } - if (!mounted) return; - setState(() {}); - for (final pane in _ctl.panes) { - _reveal(pane, pane.current); - } - } - - @override - Future layoutDialog() async { - final s = c.settings; - var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar; - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) { - Widget group(String title, String value, List<(String, String)> items, void Function(String) on) => - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(top: 8, left: 4), - child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), - ), - Wrap(spacing: 6, children: [ - for (final (v, label) in items) - ChoiceChip(label: Text(label), selected: value == v, onSelected: (_) => set(() => on(v))), - ]), - ]); - return AlertDialog( - scrollable: true, - title: Text(tr('창 배치')), - content: SizedBox( - width: 420, - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - group(tr('창'), layout, [ - ('auto', tr('화면 크기 따라')), - ('dual', tr('두 창 (기본)')), - ('split', tr('폴더 + 파일 목록')), - ('single', tr('한 창')), - ], (v) => layout = v), - if (layout == 'split') - Padding( - padding: const EdgeInsets.only(top: 6, left: 4), - child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - if (layout != 'single') - group(tr('두 창 배치'), orient, [ - ('side', tr('좌우 (기본)')), - ('stacked', tr('위아래')), - ('auto', tr('화면 모양 따라')), - ], (v) => orient = v), - group(tr('기능 버튼 줄'), bar, [ - ('middle', layout != 'single' ? tr('두 창 사이') : tr('왼쪽')), - ('edge', tr('끝 (오른쪽 · 아래)')), - ('hidden', tr('숨김')), - ], (v) => bar = v), - if (bar == 'hidden') - Padding( - padding: const EdgeInsets.only(top: 8, left: 4), - child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - ]), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), - ], - ); - }, - ), - ); - if (ok != true) return; - await c.updateSettings((x) => x - ..explorerLayout = layout - ..explorerOrientation = orient - ..explorerToolbar = bar); - if (layout == 'single') setState(() => _ctl.active = 0); - // 배치가 바뀌면 목록을 새로 그리므로 맨 위로 간다: 보던 폴더가 다시 보이게 - for (final pane in _ctl.panes) { - _reveal(pane, pane.current); - } - } - - @override - Future buttonsDialog() async { - final current = ExplorerButton.fromSettings(c.settings.explorerButtons); - final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))]; - final on = {...current}; - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - title: Text(tr('버튼 구성')), - content: SizedBox( - width: 380, - height: 460, - child: Column(children: [ - Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - const SizedBox(height: 8), - Expanded( - child: ReorderableListView( - buildDefaultDragHandles: true, - onReorderItem: (a, b) => set(() => order.insert(b, order.removeAt(a))), - children: [ - for (final b in order) - CheckboxListTile( - key: ValueKey(b), - dense: true, - value: on.contains(b), - secondary: Icon(b.icon), - title: Text(tr(b.label)), - controlAffinity: ListTileControlAffinity.leading, - onChanged: (v) => set(() => v == true ? on.add(b) : on.remove(b)), - ), - ], - ), - ), - ]), - ), - actions: [ - TextButton( - onPressed: () => set(() { - order - ..clear() - ..addAll(ExplorerButton.defaultOrder); - on - ..clear() - ..addAll(ExplorerButton.explorerValues); - }), - child: Text(tr('기본값')), - ), - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('확인'))), - ], - ), - ), - ); - if (ok != true) return; - final names = [for (final b in order) if (on.contains(b)) b.name]; - await c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names); + unawaited(_ctl.goTo(_ctl.pane, dir, fresh: true).then((_) => _layout.reveal(_ctl.pane, dir))); } // ───────── 화면 ───────── @@ -1458,12 +404,12 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex // 글자 칸보다 먼저 키를 받아, 칸에서 Delete 를 누르면 파일 지우기 창이 뜨고 Ctrl+A · C · V · Space 도 파일에 쓰였다 final bindings = { // 83 · X-plore 비교 5-1 ⑥: 스타일에 맞는 처음 키 + 사용자가 바꾼 키 ([explorer_keys.dart], 환경 설정 > 파일 탐색기 > 단축키) - ..._keyBindings(), + ..._input.bindings(context), // 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) - Esc 는 바꾸지 않는다 const SingleActivator(LogicalKeyboardKey.escape): () { // 76: 열린 빠른 필터부터 닫는다 if (_ctl.panes[_ctl.active].filterOpen) { - _closeFilter(_ctl.panes[_ctl.active]); + _input.closeFilter(_ctl.panes[_ctl.active]); } else if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { _sel.setSelecting(false); } @@ -1472,15 +418,15 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex return Focus( canRequestFocus: false, skipTraversal: true, - onKeyEvent: (_, ev) => _shortcut(bindings, ev), + onKeyEvent: (_, ev) => _input.shortcut(bindings, ev), child: Focus( - focusNode: _keys, + focusNode: _input.focus, autofocus: true, // 76: 목록에서 글자를 바로 치면 지금 창의 빠른 필터가 그 글자로 열린다 (One Commander · TC 처럼) - onKeyEvent: (_, ev) => _typeToFilter(ev), + onKeyEvent: (_, ev) => _input.typeToFilter(ev), child: Listener( - // 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [_frame]) - onPointerDown: (ev) => _mouseTravel(_ctl.active, ev), + // 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [ExplorerPaneFrame]) + onPointerDown: (ev) => _input.mouseTravel(_ctl.active, ev), child: Scaffold( body: SwipeNav( current: widget.rsync ? 'rsync' : 'explorer', @@ -1490,14 +436,15 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex _ctl.dropRemovedServers(); // 80 보충: 화면에서 사라진 폴더를 위해 걸어 둔 자동 크기 계산은 멈추고 뺀다 _ctl.sizes.retainAuto([for (final x in _ctl.panes) x.current]); + final body = ExplorerPanesBody(ctl: _ctl, sel: _sel, layout: _layout, parts: this, buttons: _buttons, flows: _flows); return Column(children: [ - _topBar(), - if (_searchBack != null) _searchBackBar(), + ExplorerTopBar(ctl: _ctl, buttons: _buttons), + ?_flows.search.backBar(context), // 89: Wi-Fi 공유가 켜져 있는 동안 if (!widget.rsync) ValueListenableBuilder( valueListenable: WifiShare.current, - builder: (_, s, _) => s == null ? const SizedBox.shrink() : _wifiBar(s), + builder: (_, s, _) => s == null ? const SizedBox.shrink() : WifiShareBar(c: c, share: s), ), Expanded( child: Stack(children: [ @@ -1505,17 +452,17 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex Positioned.fill( child: !_ctl.ready ? const Center(child: CircularProgressIndicator()) - : _swipePanes + : _layout.swipePanes ? GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragEnd: (d) { final v = d.primaryVelocity ?? 0; - if (v.abs() >= 500) _switchPane(v < 0 ? 1 : -1); + if (v.abs() >= 500) _layout.switchPane(v < 0 ? 1 : -1); }, - child: _body()) - : _body()), + child: body) + : body), // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 - Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _jobs.list, rsync: widget.rsync)), + Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _flows.jobs.list, rsync: widget.rsync)), ]), ), ]); @@ -1527,506 +474,4 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex ), ); } - - /// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터) - List _ancestors(ExplorerPane pane) { - final out = []; - var d = pane.current; - for (var i = 0; i < 64; i++) { - out.add(d); - if (samePath(d, pane.root)) break; - final up = vDirname(d); - if (up == d || up.isEmpty) break; - d = up; - } - return out; - } - - Widget _topBar() => AppTopBar( - nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync), - actions: AppActions(c: c), - // 아주 좁고 글자가 크면 (300 폭 · 1.3 × 150%) 제목도 줄임 - 넘쳐 그리기 오류가 나던 것 (날짜 열 312 조합 시험에서 찾음). - // 보통은 제목 폭 그대로 (경로 막대에 80 남짓 남을 때까지) - middle: LayoutBuilder(builder: (context, box) => Row(children: [ - ConstrainedBox( - constraints: BoxConstraints(maxWidth: math.max(40, box.maxWidth - 92)), - child: Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), - maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - ), - const SizedBox(width: 12), - // X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 - Expanded( - child: !_ctl.ready - ? const SizedBox.shrink() - : PopupMenuButton( - tooltip: tr('위 폴더로 가기'), - position: PopupMenuPosition.under, - onSelected: (d) => _ctl.goTo(_ctl.pane, d), - itemBuilder: (_) => [ - for (final d in _ancestors(_ctl.pane)) - PopupMenuItem( - value: d, - enabled: !samePath(d, _ctl.pane.current), - child: Text(_ctl.displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis), - ), - ], - // 관리자 지시 (10/21, 28 지적): 좁으면 앞을 "…" 로 - 지금 폴더 (끝) 가 늘 보이게 - child: TailText(_ctl.displayPath(_ctl.pane.current), style: TextStyle(color: JjColors.textDim)), - ), - ), - PopupMenuButton( - tooltip: tr('창 배치 · 버튼 구성'), - icon: const Icon(Icons.more_vert), - onSelected: (v) => v is ExplorerButton ? _buttons.press(v) : _ctl.toggleAppHidden(), - itemBuilder: (_) => [ - for (final b in [ - if (!widget.rsync) ...[ExplorerButton.layout, ExplorerButton.buttons], - ExplorerButton.sort, - ExplorerButton.hidden, - if (!widget.rsync) ExplorerButton.storage, - if (!widget.rsync) ExplorerButton.wifiShare, - ]) - PopupMenuItem( - value: b, - child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]), - ), - // X-plore 숨기기: 큰 폴더를 펼쳐 목록 맨 아래가 멀 때도 (관리자 결정 10/18) - if (!widget.rsync && c.settings.explorerHiddenItems.isNotEmpty) - PopupMenuItem( - value: 'appHidden', - child: Row(children: [ - Icon(_ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), - const SizedBox(width: 12), - Flexible( - child: Text(_ctl.showAppHidden - ? tr('목록에서 숨긴 항목 다시 숨기기') - : trf('목록에서 숨긴 항목 보이기 ({0}개)', [c.settings.explorerHiddenItems.length])), - ), - ]), - ), - ], - ), - ])), - ); - - /// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로 - String _shape = ''; - - Widget _body() { - final s = c.settings; - final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}'; - if (_ctl.ready && shape != _shape) { - if (_shape.isNotEmpty) { - for (final pane in _ctl.panes) { - _reveal(pane, pane.current); - } - } - _shape = shape; - } - final dual = _dual; - return LayoutBuilder(builder: (context, box) { - // 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼. - // 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다 - final wantSplit = !widget.rsync && s.explorerLayout == 'split'; - final textScale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale; - final barW = s.explorerToolbar == 'hidden' ? 0.0 : 72.0; - final sideNow = switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight }; - final tightWas = _splitTight; - _splitTight = wantSplit && sideNow && box.maxWidth - barW < 182 + 90 * textScale; - // 바뀌면 이 builder 밖 (버튼 이름 "두 창" 등) 도 한 번 다시 그린다 - if (tightWas != _splitTight) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? setState(() {}) : null); - final split = _split; - // 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게 - if (_ctl.ready) _revealNewViews(); - final side = !(dual || split) || - switch (s.explorerOrientation) { - 'side' => true, - 'stacked' => false, - _ => box.maxWidth >= box.maxHeight, - }; - // 191: Rsync 화면이 좁으면 (폴드 접은 화면 · 폰 세로의 좌우 두 창) 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 본다 - // (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다) - final rsyncOne = widget.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth; - _narrowSplit = split && side && box.maxWidth < narrowSplitWidth; - final bar = s.explorerToolbar == 'hidden' && !widget.rsync - ? null - : ExplorerToolbar( - // X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음) - onLongPress: widget.rsync ? null : _buttons.longPress, - // Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에 - buttons: widget.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons), - vertical: side && !rsyncOne, - panesSide: side, - onPressed: _buttons.press, - enabled: (b) => switch (b) { - ExplorerButton.paste => _transfers.clip.isNotEmpty, - _ => true, - }, - selected: (b) => - (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _sel.selecting), - twoPanes: dual || split, - pinned: {ExplorerButton.orient, if (_transfers.clip.isNotEmpty) ExplorerButton.paste}, - ); - final middle = bar != null && (widget.rsync || s.explorerToolbar == 'middle'); - final edge = bar != null && !widget.rsync && s.explorerToolbar == 'edge'; - // 180: 창마다 같은 열쇠 - 가로 ↔ 세로 배치가 바뀌어도 (창 크기 · 작업 현황이 열림) 창을 새로 만들지 않는다 (스크롤이 맨 위로 가지 않게) - Widget keep(int k, Widget w) => KeyedSubtree(key: _viewKeys[k], child: w); - if (rsyncOne) { - final i = _ctl.active.clamp(0, 1); - return Column(children: [ - _rsyncPaneSwitch(), - ?bar, - Expanded(child: keep(i, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: i, foldersOnly: true))), - ]); - } - final children = [ - if (dual) ...[ - Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: widget.rsync))), - if (middle) bar, - Expanded(child: keep(1, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 1, foldersOnly: widget.rsync))), - if (edge) bar, - ] else if (split) ...[ - // 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) - - // 버튼 줄까지 넣어 38% 로 하면 360 폭에서 오른쪽 목록의 파일 이름이 두세 글자만 남았다 - if (_narrowSplit) - SizedBox( - width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42), - child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true))) - else - Expanded(flex: 2, child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0, foldersOnly: true))), - if (middle) bar, - Expanded(flex: 3, child: keep(2, ExplorerListView(ctl: _ctl, sel: _sel, parts: this, pane: _ctl.panes[0]))), - if (edge) bar, - ] else ...[ - if (middle) bar, - Expanded( - child: _hiddenTarget - ? Column(children: [ListenableBuilder(listenable: _ctl.panes[1], builder: (_, _) => _targetBar()), Expanded(child: keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0)))]) - : keep(0, ExplorerPaneView(ctl: _ctl, sel: _sel, parts: this, index: 0))), - if (edge) bar, - ], - ]; - return side ? Row(children: children) : Column(children: children); - }); - } - - /// 191: 좁은 Rsync 화면의 [왼쪽 | 오른쪽] - 그 창에서 고른 폴더 (없으면 지금 폴더) 이름과 함께 - Widget _rsyncPaneSwitch() => Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 4, 0), - child: Row(children: [ - for (final (k, side) in [(0, tr('왼쪽')), (1, tr('오른쪽'))]) - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: ChoiceChip( - showCheckmark: false, - selected: _ctl.active == k, - onSelected: (_) => setState(() => _ctl.active = k), - label: SizedBox( - width: double.infinity, - child: Text( - '$side · ${vBasename(!_ctl.panes[k].sel.isEmpty ? _ctl.panes[k].sel.marked.first : _ctl.panes[k].current)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - ), - ]), - ); - - /// 180: 왼쪽 창 · 오른쪽 창 · 파일 목록 (배치가 바뀌어도 같은 위젯 - [_body]) - final _viewKeys = [GlobalKey(), GlobalKey(), GlobalKey()]; - - /// 75: 마우스 4 · 5번 - 그 창의 뒤로 · 앞으로 (그 창을 활성으로). - /// 창 위의 누름은 창 ([_frame]) 과 화면 Listener 가 둘 다 받는다. 받는 곳마다 좌표를 바꾼 새 이벤트라 객체로는 같은지 알 수 없어 - /// 누른 손가락 (pointer) · 시각으로 한 번만 쓴다 (창이 먼저 받는다) - (int, Duration)? _navPress; - void _mouseTravel(int i, PointerDownEvent ev) { - if (i >= _ctl.panes.length) return; - final back = ev.buttons & kBackMouseButton != 0; - if (!back && ev.buttons & kForwardMouseButton == 0) return; - final press = (ev.pointer, ev.timeStamp); - if (press == _navPress) return; - _navPress = press; - _ctl.travel(_ctl.panes[i], forward: !back); - } - - Widget _frame(int i, List children) { - final active = i == _ctl.active; - return Listener( - // 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도) - onPointerDown: (ev) => _mouseTravel(i, ev), - child: GestureDetector( - onTapDown: (_) { - if (_ctl.active != i) setState(() => _ctl.active = i); - }, - // 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리 - child: DragTarget( - onWillAcceptWithDetails: (d) => canDropInto(d.data, i, _ctl.panes[i].current), - onAcceptWithDetails: (d) => unawaited(_onDrop(d.data, i, _ctl.panes[i].current)), - builder: (context, ok, no) => Container( - margin: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg, - border: Border.all( - color: ok.isNotEmpty - ? JjColors.accent - : no.isNotEmpty - ? JjColors.danger - : active - ? JjColors.accent - : JjColors.border, - width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)), - borderRadius: BorderRadius.circular(6), - ), - child: Column(children: children), - ), - ), - ), - ); - } - - /// 82: 끌 항목 - 누른 것이 표시한 것 중 하나면 표시한 것 모두 (바깥 것만), 아니면 그것 하나 - List _dragPaths(ExplorerPane pane, FileEntry e) => - _sel.isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path]; - - /// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널). - /// 이동이 끝나면 아래 알림에 [되돌리기] (10초) - Future _onDrop(DragLoad load, int to, String dest) async { - if (!canDropInto(load, to, dest) || !mounted) return; - // X-plore 비교 f: 압축 안으로는 놓지 않고, 압축 안의 것은 꺼내기로 - if (_guard.insideArchive(dest) || _ctl.arc.contains(dest)) { - _snack(tr('압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)')); - return; - } - if (load.paths.any(_guard.insideArchive)) { - await _guard.extractFrom(context, load.paths, dest); - return; - } - setState(() => _ctl.active = to); - final mode = c.settings.explorerDropAction; - final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move'; - if (move == null || !mounted) return; - // 되돌리기에 쓸 것: 대상에 이미 있던 이름 (합친 폴더는 통째로 되돌리지 않게) - final existed = {}; - if (move) { - for (final s in load.paths) { - final t = vJoin(dest, vBasename(s)); - if (await vExists(t)) existed.add(t); - } - } - if (!mounted) return; - await _transfers.run(context, _ctl.panes[load.from], load.paths, dest, - move: move, onFinished: move ? (job) => _offerUndoMove(load, job, existed) : null); - } - - /// 82: 이동 되돌리기 알림. 실제로 옮긴 것만 원래 자리로: 새로 생긴 항목은 통째로, 이미 있던 폴더에 합친 것은 앱이 옮긴 파일만 - /// (robocopy 로 합친 폴더는 무엇을 옮겼는지 몰라 되돌리지 않고 알린다) - void _offerUndoMove(DragLoad load, TransferJob job, Set existed) { - // (원래 폴더 → 지금 경로들) - final back = >{}; - // 되돌리지 않는 것: 합친 폴더 (robocopy - 무엇을 옮겼는지 모름) · 같은 이름을 덮어쓴 파일 (원래 파일은 이미 없음, 15 지적) - var notUndoable = 0, overwritten = 0; - final pairs = job.made.length == load.paths.length - ? [for (var i = 0; i < load.paths.length; i++) (load.paths[i], job.made[i])] - : [ - for (final m in job.made) - for (final s in load.paths) - if (vBasename(s) == vBasename(m)) (s, m), - ]; - for (final (src, now) in pairs) { - if (!existed.contains(now)) { - back.putIfAbsent(vDirname(src), () => []).add(now); - continue; - } - // 같은 이름을 덮어쓴 파일: 되돌리면 대상에 아무것도 남지 않으므로 그대로 둔다 - if (!vIsDirSync(now)) { - overwritten++; - continue; - } - // 합친 폴더: 그 안에서 실제로 옮긴 파일만 - final files = [for (final f in job.movedFiles) if (isSameOrInside(f, src) && !samePath(f, src)) f]; - if (files.isEmpty) { - notUndoable++; - continue; - } - for (final f in files) { - final rest = f.substring(src.length + 1); - back.putIfAbsent(vDirname(f), () => []).add(vJoin(now, rest)); - } - } - if (back.isEmpty || !mounted) return; - final moved = back.values.fold(0, (a, l) => a + l.length); - showNotice(ScaffoldMessenger.of(context)..hideCurrentSnackBar(), - text: joinSentences([ - trf('{0}개를 옮겼습니다.', [moved]), - if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), - if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]), - ]), - action: (tr('되돌리기'), () => unawaited(_undoMove(_ctl.panes[load.from], back)))); - } - - /// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창) - Future _undoMove(ExplorerPane pane, Map> back) async { - for (final MapEntry(key: dir, value: items) in back.entries) { - final there = [for (final x in items) if (await vExists(x)) x]; - if (there.isEmpty || !mounted) continue; - try { - if (!await vExists(dir)) await vMkdirs(dir); - } catch (_) {} - if (!mounted) return; - await _transfers.run(context, pane, there, dir, move: true); - } - } - - /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 - /// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작) - void _openFilter(ExplorerPane pane, [String? text]) { - setState(() { - _ctl.active = _ctl.panes.indexOf(pane); - pane.filterOpen = true; - if (text != null) { - pane.filterCtl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)); - } - }); - WidgetsBinding.instance.addPostFrameCallback((_) => pane.filterFocus.requestFocus()); - // 거른 결과 (지금 폴더 아래) 가 보이게 - _reveal(pane, pane.current); - } - - /// 76: 닫으면 글자도 지워 모두 보인다. 줄이 다시 늘어나 맨 위로 가지 않게 고른 것 (없으면 지금 폴더) 으로 - void _closeFilter(ExplorerPane pane) { - setState(() { - pane - ..filterOpen = false - ..filterCtl.clear(); - }); - _reveal(pane, pane.focused ?? pane.current); - _keys.requestFocus(); - } - - /// 83: 지금 스타일이 Total Commander 인지 (Rsync 화면은 스타일 단축키를 쓰지 않는다 - 늘 같은 버튼) - bool get _tcKeys => !widget.rsync && Platform.isWindows; - - /// 83 · X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 동작마다의 키 → 할 일. 처음 키는 스타일대로 (Total Commander: F5 복사 · F6 이동 · - /// F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침 / Windows 탐색기 · X-plore: Ctrl+C · X · V · A · F5 새로 고침), - /// 사용자가 바꾼 것은 설정 explorerKeyOverrides. Rsync 화면은 늘 같은 키 (바꾼 것을 쓰지 않음) - Map _keyBindings() { - final cb = { - 'copy': () => _buttons.press(ExplorerButton.copy), - 'move': () => _buttons.press(ExplorerButton.move), - 'newFolder': () => _buttons.press(ExplorerButton.newFolder), - 'delete': () => _buttons.press(ExplorerButton.delete), - // 65: Shift+Delete = 휴지통을 거치지 않고 (창에서 한 번 더 확인) - 'deletePermanent': () { - _deletes.shiftDelete = true; - _buttons.press(ExplorerButton.delete); - }, - 'rename': () => _buttons.press(ExplorerButton.rename), - 'refresh': () => _buttons.press(ExplorerButton.refresh), - // 81: 빠른 보기 - 'quickLook': () => quickLook(_ctl.pane), - 'clipCopy': () => _transfers.toClipboard(_sel.targets(_ctl.pane), move: false), - 'clipCut': () => _transfers.toClipboard(_sel.targets(_ctl.pane), move: true), - 'paste': () => _transfers.paste(context, _ctl.pane, _ctl.pane.current), - 'selectAll': () { - if (!_sel.selecting) setState(() => _sel.selecting = true); - _sel.markWhere(_ctl.pane, _ctl.pane.current, (_) => true); - }, - // TC: Space 표시 · Insert 표시하고 다음 줄로 - 'mark': () => _markFocused(next: false), - 'markNext': () => _markFocused(next: true), - 'filter': () => _openFilter(_ctl.panes[_ctl.active]), - 'up': () => _buttons.press(ExplorerButton.up), - // 75: 지금 창의 뒤로 · 앞으로 - 'back': () => _ctl.travel(_ctl.panes[_ctl.active], forward: false), - 'forward': () => _ctl.travel(_ctl.panes[_ctl.active], forward: true), - 'switchPane': () => setState(() => _ctl.active = _dual ? 1 - _ctl.active : 0), - }; - final keys = widget.rsync - ? { - for (final e in defaultExplorerKeys(tc: false).entries) - if (const {'delete', 'deletePermanent', 'rename', 'refresh', 'filter', 'up', 'back', 'forward', 'switchPane'}.contains(e.key)) - e.key: e.value, - } - : effectiveExplorerKeys(tc: _tcKeys, overrides: c.settings.explorerKeyOverrides); - return { - for (final e in keys.entries) - if (cb[e.key] != null) - for (final a in e.value) a: cb[e.key]!, - }; - } - - /// 81: 빠른 보기 - 지금 줄 (없으면 아무것도 안 함). 같은 폴더에 보이는 항목을 ← → 로 - @override - Future quickLook(ExplorerPane pane, [String? path]) async { - final f = path ?? pane.focused; - if (f == null || samePath(f, pane.root)) return; - final list = _sel.shown(pane, vDirname(f)); - final i = list.indexWhere((x) => samePath(x.path, f)); - if (i < 0) return; - await showDialog( - context: context, - builder: (_) => QuickLook( - entries: list, - index: i, - probe: c.services.mediaTool.probe, - thumbnail: (e) => BigVideoThumbs.of(c, e), - imageExts: c.settings.imageExts, - onOpen: (e) => e.isDir ? unawaited(_ctl.goTo(pane, e.path)) : unawaited(_opener.open(context, e.path, pane: pane)), - ), - ); - } - - /// 83: (TC) 지금 줄을 표시 · 풀기, [next] 면 다음 줄로 - Future _markFocused({required bool next}) async { - final pane = _ctl.pane; - final f = pane.focused; - if (f == null || samePath(f, pane.root)) return; - final list = _sel.shown(pane, vDirname(f)); - final i = list.indexWhere((x) => samePath(x.path, f)); - if (i < 0) return; - if (!_sel.selecting) setState(() => _sel.selecting = true); - await _sel.toggleMark(pane, list[i]); - if (next && i + 1 < list.length) { - pane.focused = list[i + 1].path; - pane.changed(); - _reveal(pane, list[i + 1].path); - } - } - - /// 글자 칸에 치는 중인지 (그 칸이 키를 받아야 한다) - static bool get _typing => FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType() != null; - - /// 탐색기 단축키: 글자 칸에 치는 중이면 넘기고 (칸의 기본 동작 - 선택 · 복사 · 붙여넣기 · 지우기 · 빈칸), 아니면 맞는 것을 한다 - KeyEventResult _shortcut(Map bindings, KeyEvent ev) { - if (ev is KeyUpEvent || _typing) return KeyEventResult.ignored; - for (final b in bindings.entries) { - if (b.key.accepts(ev, HardwareKeyboard.instance)) { - b.value(); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - } - - KeyEventResult _typeToFilter(KeyEvent ev) { - if (ev is! KeyDownEvent) return KeyEventResult.ignored; - final ch = ev.character; - final kb = HardwareKeyboard.instance; - if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored; - // 83 · 81: Space 는 표시 (TC) · 빠른 보기 (그 밖) - 빠른 필터를 열지 않는다 (필터가 열려 있으면 그대로 글자) - if (ch == ' ' && !widget.rsync && !_ctl.panes[_ctl.active].filterOpen) return KeyEventResult.ignored; - if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored; - // 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다 - if (_typing) return KeyEventResult.ignored; - if (!_ctl.ready || _ctl.panes.isEmpty) return KeyEventResult.ignored; - final pane = _ctl.panes[_ctl.active]; - _openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch); - return KeyEventResult.handled; - } - } - diff --git a/test/explorer_buttons_contract_test.dart b/test/explorer_buttons_contract_test.dart index 75db542..c679187 100644 --- a/test/explorer_buttons_contract_test.dart +++ b/test/explorer_buttons_contract_test.dart @@ -9,20 +9,15 @@ 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/delete_flow.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_flows.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; -import 'package:jj_mkvmaker/ui/explorer/explorer_jobs.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/open_flow.dart'; -import 'package:jj_mkvmaker/ui/explorer/rsync_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/toolbar.dart'; -import 'package:jj_mkvmaker/ui/explorer/transfer_flow.dart'; import 'package:jj_mkvmaker/ui/explorer/transfer_panel.dart'; -import 'package:jj_mkvmaker/ui/explorer/write_guard.dart'; import 'package:path/path.dart' as p; /// E5d-4 약속 시험: explorer_buttons · toolbar · marks_bar · transfer_panel · job_view 맨 위 약속 @@ -53,19 +48,9 @@ void main() { ctl.dispose(); }); await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); - final guard = ExplorerGuard(ctl); - final jobs = ExplorerJobs(ctl); - final transfers = ExplorerTransfers(ctl, guard, jobs); - addTearDown(() { - transfers.dispose(); - jobs.dispose(); - }); - final b = ExplorerButtons(ctl, sel, host, - guard: guard, - transfers: transfers, - deletes: ExplorerDeleteFlow(ctl, sel, guard, androidTrash: () => false), - rsync: ExplorerRsyncFlow(ctl, jobs, transfers), - opener: ExplorerOpener(ctl, sel, guard, otherDirOf: (_) => null)); + final flows = ExplorerFlows(ctl, sel, androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => null, twoPanes: () => false); + addTearDown(flows.dispose); + final b = ExplorerButtons(ctl, sel, host, flows); return (b, ctl, sel, host); } @@ -87,15 +72,15 @@ void main() { expect([Directory(made).existsSync(), samePath(q.focused!, made)], [true, true]); await t.runAsync(() => sel.toggleMark(q, sel.shown(q, show).firstWhere((e) => samePath(e.path, ep1)))); await t.runAsync(() => b.press(ExplorerButton.copy)); - expect([b.transfers.clip.items, b.transfers.clip.move], [ + expect([b.flows.transfers.clip.items, b.flows.transfers.clip.move], [ [ep1], false ]); await t.runAsync(() => b.longPress(ExplorerButton.copy)); - expect(b.transfers.clip.move, isTrue, reason: '복사 길게 = 이동'); + expect(b.flows.transfers.clip.move, isTrue, reason: '복사 길게 = 이동'); unawaited(b.longPress(ExplorerButton.delete)); await t.pump(); - expect([find.text('영구 삭제').evaluate().isNotEmpty, b.deletes.shiftDelete], [true, false]); + expect([find.text('영구 삭제').evaluate().isNotEmpty, b.flows.deletes.shiftDelete], [true, false]); await t.tap(find.text('취소')); await t.pump(); expect(File(ep1).existsSync(), isTrue, reason: '취소하면 그대로'); @@ -176,7 +161,7 @@ class _Host extends StatefulWidget { State<_Host> createState() => _HostState(); } -/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패) +/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands { Widget Function()? child; @@ -201,34 +186,7 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands String? get otherDir => null; @override bool get dual => false; - @override - Future quickLook(ExplorerPane pane, [String? path]) => _no(); - Never _no() => throw UnimplementedError('이 시험에서 부르지 않음'); - @override - Future addMkv(List paths) => _no(); - @override - Future renameItem(ExplorerPane pane, String path) => _no(); - @override - Future batchRename(ExplorerPane pane, List paths) => _no(); - @override - Future openSearch(ExplorerPane pane) => _no(); - @override - Future openStorage(ExplorerPane pane, String dir) => _no(); - @override - Future openWifiShare(String dir) => _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()); diff --git a/test/explorer_flows_contract_test.dart b/test/explorer_flows_contract_test.dart index fc76c45..2bf742f 100644 --- a/test/explorer_flows_contract_test.dart +++ b/test/explorer_flows_contract_test.dart @@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer/delete_flow.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_flows.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_jobs.dart'; import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart'; @@ -57,20 +58,15 @@ void main() { final host = key.currentState!; final ctl = ExplorerController(c, rsync: false, host: host)..local = [(root, 'T:')]; final sel = ExplorerSelect(ctl, clock: DateTime.now, openFile: (_, _) async {}); - final guard = ExplorerGuard(ctl); - final jobs = ExplorerJobs(ctl); - final transfers = ExplorerTransfers(ctl, guard, jobs); - final deletes = ExplorerDeleteFlow(ctl, sel, guard, androidTrash: () => false); - final opener = ExplorerOpener(ctl, sel, guard, otherDirOf: (_) => null); + final flows = ExplorerFlows(ctl, sel, androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => null, twoPanes: () => false); addTearDown(() { - transfers.dispose(); - jobs.dispose(); + flows.dispose(); sel.dispose(); ctl.dispose(); }); await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); - final menu = ExplorerItemMenu(ctl, sel, host, guard: guard, transfers: transfers, deletes: deletes, opener: opener, nomedia: () => false); - return _Flows(host, ctl, sel, guard, jobs, transfers, deletes, opener, menu); + final menu = ExplorerItemMenu(ctl, sel, host, flows, nomedia: () => false); + return _Flows(host, ctl, sel, flows.guard, flows.jobs, flows.transfers, flows.deletes, flows.opener, menu); } testWidgets('막기: 시스템 폴더 · 루트는 읽기 전용 (까닭 알림) · 시스템 폴더로 옮기기 막음 · 보통 폴더는 그대로', (t) async { @@ -181,7 +177,7 @@ class _Host extends StatefulWidget { State<_Host> createState() => _HostState(); } -/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 (이 시험이 부르지 않는 화면의 일은 부르면 실패) +/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands { @override bool get alive => mounted; @@ -197,33 +193,6 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands @override bool get dual => false; - Never _no() => throw UnimplementedError('이 시험에서 부르지 않음'); - @override - Future quickLook(ExplorerPane pane, [String? path]) => _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 openSearch(ExplorerPane pane) => _no(); - @override - Future openStorage(ExplorerPane pane, String dir) => _no(); - @override - Future openWifiShare(String dir) => _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) => const Scaffold(body: SizedBox()); diff --git a/test/explorer_page_modules_contract_test.dart b/test/explorer_page_modules_contract_test.dart new file mode 100644 index 0000000..0045157 --- /dev/null +++ b/test/explorer_page_modules_contract_test.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/gestures.dart' show kBackMouseButton; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry, 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/access_flow.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_bars.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_flows.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart'; +import 'package:jj_mkvmaker/ui/explorer/explorer_layout.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/key_flow.dart'; +import 'package:jj_mkvmaker/ui/explorer/pane_notices.dart'; +import 'package:jj_mkvmaker/ui/explorer/server_flows.dart'; +import 'package:path/path.dart' as p; + +/// E5d-6 약속 시험: explorer_layout · access_flow · pane_notices · view_dialogs · rename_flow · tools_flow · drop_flow · key_flow · +/// explorer_bars 맨 위 약속 (실제 시험 폴더 · 실제 화면 · 실제 설정 (저장하지 않음) · 실제 이름 바꾸기) +void main() { + late Directory tmp; + late String root, show, a; + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_page_mod_'); + root = tmp.path; + show = p.join(root, 'Show'); + Directory(show).createSync(); + a = p.join(show, 'a.txt'); + File(a).writeAsStringSync('a'); + File(p.join(show, 'old.mkv.jjpart')) + ..writeAsStringSync('x') + ..setLastModifiedSync(DateTime(2026, 10, 1)); + }); + tearDown(() => tmp.deleteSync(recursive: true)); + + Future settle(WidgetTester t, [bool Function()? until]) async { + for (var i = 0; i < 30 && !(until?.call() ?? false); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(const Duration(milliseconds: 100)); + } + } + + Future<_M> open(WidgetTester t, {bool drive = true}) 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 {}); + final layout = ExplorerLayout(ctl, context: () => host.context); + final flows = ExplorerFlows(ctl, sel, + androidTrash: () => false, otherDirOf: (_) => null, otherDir: () => layout.other?.current, twoPanes: () => layout.dual || layout.split); + final access = ExplorerAccess(ctl, probe: (_) async => drive); + final input = ExplorerInput(ctl, sel, flows, ExplorerButtons(ctl, sel, host, flows), dual: () => layout.dual); + addTearDown(() { + input.dispose(); + access.dispose(); + flows.dispose(); + sel.dispose(); + ctl.dispose(); + }); + await t.runAsync(() => ctl.goTo(ctl.panes[0], show)); + await t.runAsync(() => ctl.goTo(ctl.panes[1], root)); + return _M(host, ctl, sel, layout, flows, access, input); + } + + testWidgets('배치 규칙: 두 창 · 한 창 + 숨은 갈 곳 · 폴더 + 파일 목록 (좁으면 잠깐 한 창) · 밀기 꺼짐 · 줄 높이', (t) async { + final m = await open(t); + final s = m.ctl.c.settings; + m.ctl.active = 0; + expect([m.layout.dual, m.layout.split, identical(m.layout.other, m.ctl.panes[1]), m.layout.rowHeight() > 0], [true, false, true, true]); + s.explorerLayout = 'single'; + expect([m.layout.dual, m.layout.hiddenTarget, m.layout.other], [false, false, null], reason: '처음 값은 담아 두기'); + s.explorerOnePaneCopy = 'other'; + expect([m.layout.hiddenTarget, identical(m.layout.other, m.ctl.panes[1])], [true, true]); + s.explorerLayout = 'split'; + expect(m.layout.split, isTrue); + m.layout.splitTight = true; + expect(m.layout.split, isFalse); + expect(m.layout.switchPane(1), isFalse, reason: '설정이 꺼져 있으면 밀어도 창을 바꾸지 않음'); + }); + + testWidgets('다시 연결: 열 수 없으면 까닭 알림 · 열 수 있으면 그 드라이브로 · 안내: 읽지 못함 [다시 시도] · 만들다 만 파일', (t) async { + final m = await open(t, drive: false); + final q = m.ctl.panes[0]; + await t.runAsync(() => m.access.reconnect(q, root)); + await t.pump(); + expect([find.textContaining('연결할 수 없습니다').evaluate().isNotEmpty, samePath(q.current, show)], [true, true]); + final notices = ExplorerNotices(m.ctl, m.access, ExplorerServers(m.ctl)); + q.errors[show] = 'PathAccessException: Access denied'; + m.host.child = () => Builder(builder: (ctx) => Column(children: notices.of(ctx, q, show).whereType().toList())); + await t.pump(); + expect([find.text('다시 시도').evaluate().length, find.textContaining('만들다 만 파일 1개').evaluate().length], [1, 1]); + final ok = await open(t); + await t.runAsync(() => ok.access.reconnect(ok.ctl.panes[0], root)); + expect(samePath(ok.ctl.panes[0].current, root), isTrue); + }); + + testWidgets('보기 창: 정렬 기준 고르고 확인 = 그 정렬 · 버튼 구성 [취소] = 그대로', (t) async { + final m = await open(t); + final before = [...m.ctl.c.settings.explorerButtons]; + unawaited(m.flows.dialogs.sortDialog(m.host.context)); + await t.pump(); + await t.tap(find.text('크기')); + await t.pump(); + await t.tap(find.text('확인')); + await settle(t, () => m.ctl.sortOf(show).$1 == 'size'); + expect(m.ctl.sortOf(show).$1, 'size'); + unawaited(m.flows.dialogs.buttonsDialog(m.host.context)); + await t.pump(); + await t.tap(find.text('취소')); + await t.pump(); + expect(m.ctl.c.settings.explorerButtons, before); + }); + + testWidgets('이름 바꾸기: 이름 창 → 실제로 바뀜 · 초점이 새 이름으로 / Wi-Fi 공유는 원격 폴더면 알림', (t) async { + final m = await open(t); + final q = m.ctl.panes[0]; + var done = false; + unawaited(m.flows.renames.rename(m.host.context, q, a).then((_) => done = true)); + await t.pump(); + await t.enterText(find.byType(TextField), 'b.txt'); + await t.tap(find.text('확인')); + await settle(t, () => done); + final b = p.join(show, 'b.txt'); + expect([File(a).existsSync(), File(b).existsSync(), samePath(q.focused!, b)], [false, true, true]); + await m.flows.tools.openWifiShare(m.host.context, 'dav://srv/x'); + await t.pump(); + expect(find.text('Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다'), findsOneWidget); + }); + + testWidgets('끌어 놓기 항목 · 빠른 필터 열고 닫기 · 마우스 뒤로 · 단축키 목록', (t) async { + final m = await open(t); + final q = m.ctl.panes[0]; + FileEntry fe(String path, {bool dir = false}) => FileEntry(path, isDir: dir, modified: DateTime(0)); + expect(m.flows.drops.dragPaths(q, fe(a)), [a]); + m.sel.setSelecting(true); + await t.runAsync(() => m.sel.toggleMark(q, fe(show, dir: true))); + expect(m.flows.drops.dragPaths(q, fe(a)), [show], reason: '표시한 것 중 하나면 표시한 것의 바깥 것만'); + m.input.openFilter(q, 'a'); + expect([q.filterOpen, q.filterCtl.text, m.ctl.active], [true, 'a', 0]); + m.input.closeFilter(q); + expect([q.filterOpen, q.filterCtl.text], [false, '']); + await t.runAsync(() => m.ctl.goTo(q, root)); + m.input.mouseTravel(0, const PointerDownEvent(buttons: kBackMouseButton)); + await settle(t, () => samePath(q.current, show)); + expect(samePath(q.current, show), isTrue, reason: '4번 단추 = 그 창의 뒤로'); + expect(m.input.bindings(m.host.context).isNotEmpty, isTrue); + }); + + testWidgets('막대: [왼쪽 | 오른쪽] 누르면 그 창 · 갈 곳 줄 [지금 폴더로]', (t) async { + final m = await open(t); + m.host.child = () => Column(children: [RsyncPaneSwitch(ctl: m.ctl), CopyTargetBar(ctl: m.ctl)]); + await t.pump(); + await t.tap(find.textContaining('오른쪽')); + expect(m.ctl.active, 1); + m.ctl.active = 0; + m.host.child = () => CopyTargetBar(ctl: m.ctl); + await t.pump(); + await t.tap(find.text('지금 폴더로')); + await settle(t, () => samePath(m.ctl.panes[1].current, show)); + expect(samePath(m.ctl.panes[1].current, show), isTrue); + }); +} + +class _M { + _M(this.host, this.ctl, this.sel, this.layout, this.flows, this.access, this.input); + final _HostState host; + final ExplorerController ctl; + final ExplorerSelect sel; + final ExplorerLayout layout; + final ExplorerFlows flows; + final ExplorerAccess access; + final ExplorerInput input; +} + +class _Host extends StatefulWidget { + const _Host({super.key}); + @override + State<_Host> createState() => _HostState(); +} + +/// 실제 화면: ExplorerHost · ExplorerCommands 를 이 화면이 맡는다 +class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands { + Widget Function()? _child; + set child(Widget Function() f) => setState(() => _child = f); + + @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 + String? get otherDir => null; + @override + bool get dual => true; + + @override + Widget build(BuildContext context) => Scaffold(body: _child?.call() ?? const SizedBox()); +}