export 'explorer/path_dialog.dart' show looksLikeSmb, smbToUnc; export 'explorer/explorer_buttons.dart' show ExplorerButton; export 'explorer/transfer_panel.dart' show TransferProgressChip; import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../app/app_controller.dart'; import '../app/explorer_device.dart'; import '../app/copy_center.dart'; import '../core/file_ops.dart'; import 'app_actions.dart'; import '../core/thumb_cache.dart' show thumbMemoryForgetters; import 'explorer_drag.dart'; import '../core/wifi_share.dart' show WifiShare; import '../app/volumes.dart'; import 'explorer/style_ask.dart'; import 'explorer/explorer_controller.dart'; import 'explorer/explorer_host.dart'; import 'explorer/explorer_pane.dart'; import 'explorer/file_thumbs.dart'; import 'explorer/explorer_select.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/transfer_panel.dart'; import 'explorer/marks_bar.dart'; import 'explorer/server_flows.dart'; export '../core/explorer/tree_rows.dart' show globToRegExp, quickFilterMatch; export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// /// - 폴더를 누르면 그 자리에서 펼치고 · 접는다 (트리). 마지막으로 누른 폴더가 그 창의 "지금 폴더". /// - 동영상을 누르면 기본 동작 (내장 플레이어, 또는 환경 설정 > 재생 > 확장자별 재생 프로그램), /// 길게 누르거나 오른쪽 클릭하면 메뉴 (내장 플레이어 · 다른 앱으로 열기 · MKV 목록에 추가 · 이름 바꾸기 …). /// - 오른쪽 동그라미로 여러 개를 표시해 복사 · 이동 · 삭제 · 재생한다. 복사 · 이동은 다른 창의 지금 폴더로. /// - 창 배치 (두 창 / 한 창, 좌우 / 위아래, 버튼 줄 위치) 와 버튼 구성은 바꿀 수 있다 (위쪽 ⋮ 메뉴). /// /// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다. /// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다. /// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 - /// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다 class ExplorerPage extends StatefulWidget { final AppController c; final bool rsync; /// 지금 시각 (폴더 두 번 누르기의 두 번째인지 가르는 데). 시험은 정한 시각을 넘긴다 - 실제 시간에 기대 우연히 통과하지 않게 final DateTime Function() clock; /// 저장 장치 · 드라이브 · Android 에서만 하는 것 (앱은 이 기기 그대로, 시험은 시험 폴더) final ExplorerDevice device; const ExplorerPage( {super.key, required this.c, this.rsync = false, this.clock = DateTime.now, this.device = const RealExplorerDevice()}); static const routeName = 'explorer'; static const rsyncRouteName = 'rsync'; static int _open = 0, _openRsync = 0; /// 파일 탐색기로: 이미 열려 있으면 그 화면으로 돌아간다 (보던 폴더 그대로) static Future open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false); /// Rsync 화면으로 (이미 열려 있으면 그 화면으로) static Future openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true); /// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등 static Future openAt(NavigatorState nav, {required AppController c, required String dir}) { goToRequest.value = dir; return _show(nav, c, rsync: false); } /// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다) static final goToRequest = ValueNotifier(null); static Future _show(NavigatorState nav, AppController c, {required bool rsync}) async { final name = rsync ? rsyncRouteName : routeName; if ((rsync ? _openRsync : _open) > 0) { var found = false; nav.popUntil((r) { if (r.settings.name == name) found = true; return found || r.isFirst; }); if (found) return; } await nav.push(MaterialPageRoute( settings: RouteSettings(name: name), builder: (_) => ExplorerPage(c: c, rsync: rsync), )); } @override State createState() => _ExplorerPageState(); } class _ExplorerPageState extends State implements ExplorerHost, ExplorerPaneParts, ExplorerCommands { AppController get c => widget.c; /// 탐색기의 상태 · 화면 밖의 일 (E5d-1) - 화면은 부르기만. 화면에 부탁할 것은 [ExplorerHost] 로 이 상태가 해 준다 late final _ctl = ExplorerController(c, rsync: widget.rsync, host: this, fs: widget.device.fs)..addListener(_onPane); @override bool get alive => mounted; @override void snack(String text) => _snack(text); @override void reveal(ExplorerPane pane, String path) => _layout.reveal(pane, path); @override void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f()); /// 창 배치 규칙 · 스크롤 (E5d-6) late final _layout = ExplorerLayout(_ctl, context: () => context); /// 키보드 · 마우스 (E5d-6) late final _input = ExplorerInput(_ctl, _sel, _flows, _buttons, dual: () => _layout.dual); @override void initState() { // 관리자 지시 (10/21): 보관함에 넣은 파일의 썸네일 기억을 잊게 (static 함수라 여러 번 넣어도 하나) thumbMemoryForgetters ..add(VideoThumbs.forget) ..add(BigVideoThumbs.forget); super.initState(); if (widget.rsync) { ExplorerPage._openRsync++; } else { ExplorerPage._open++; } for (final pane in _ctl.panes) { pane.addListener(_onPane); } _resumeReload = AppLifecycleListener(onResume: () => unawaited(_ctl.reloadOpen(localOnly: true))); _hiddenSeen = c.settings.explorer.hiddenItems; _kindSeen = c.settings.explorer.sortByKind; c.addListener(_onSettings); _init(); // E6: 예전 판이 저장한 X-plore 모양이면 한 번 묻는다 (Windows - Rsync 화면은 묻지 않음) if (!widget.rsync) afterFrame(() => mounted ? unawaited(askExplorerStyleOnce(context, c)) : null); } /// 232: 앱으로 돌아오면 (다른 앱 · 탐색기에서 파일을 바꾸고 오면) 보이는 폴더를 다시 읽는다 late final AppLifecycleListener _resumeReload; @override void dispose() { c.removeListener(_onSettings); _input.dispose(); if (widget.rsync) { ExplorerPage._openRsync--; } else { ExplorerPage._open--; ExplorerPage.goToRequest.removeListener(_onGoToRequest); } _flows.dispose(); _sel.dispose(); _ctl.dispose(); _access.dispose(); _resumeReload.dispose(); super.dispose(); } void _onPane() { if (mounted) setState(() {}); } Future _init() async { _ctl.local = await widget.device.fs.volumes(); if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; // X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서) if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0)); if (Platform.isAndroid) unawaited(_access.checkAccess()); final saved = widget.rsync ? c.settings.copy.rsyncPaths : c.settings.explorer.paths; // 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게 // 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유) final reach = await Future.wait( [for (var i = 0; i < 2; i++) i < saved.length ? reachableAsync(saved[i]) : Future.value(false)]); // U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을 // 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로 final wants = [ for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _ctl.local[i < _ctl.local.length ? i : 0].$1, ]; // 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신) final asked = widget.rsync ? null : ExplorerPage.goToRequest.value; if (asked != null) { ExplorerPage.goToRequest.value = null; wants[0] = asked; } for (var i = 0; i < 2; i++) { _ctl.panes[i] ..root = _ctl.volumeOf(wants[i]) ..current = wants[i] // 48 보충 (관리자): 처음 열면 아무것도 고르지 않은 상태 - 열자마자 폴더 통째가 복사 · 이동 대상이 되지 않게 ..focused = null; } if (!mounted) return; setState(() => _ctl.ready = true); // (이미 열린 화면에 들어온 요청은 그 화면에서) if (!widget.rsync) ExplorerPage.goToRequest.addListener(_onGoToRequest); // 52: 다른 화면에 다녀와도 아직 도는 복사 · 이동의 진행 막대를 다시 보이고, 끝나면 그 폴더들을 새로 읽는다 final center = CopyCenter.peekOf(c); if (center != null) { for (final job in center.jobs.values.where((j) => !j.finished)) { _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(_access.probeDrive(path)); } } for (var i = 0; i < 2; i++) { final pane = _ctl.panes[i]; unawaited(_ctl.goTo(pane, wants[i], remember: false).then((_) => _layout.reveal(pane, pane.current))); } } /// 설정 화면에서 숨긴 목록을 바꾸면 (풀기) 바로 목록에 보이게 List? _hiddenSeen; /// 종류별 기본 정렬 (5-1 ①) 을 켜고 끈 것 - 바뀌면 읽어 둔 목록을 다시 줄 세운다 bool? _kindSeen; void _onSettings() { if (!mounted) return; if (_kindSeen != null && _kindSeen != c.settings.explorer.sortByKind) { for (final pane in _ctl.panes) { for (final k in pane.cache.keys.toList()) { pane.cache[k] = _ctl.sorted(pane.cache[k]!, k); } pane.changed(); } } _kindSeen = c.settings.explorer.sortByKind; final now = c.settings.explorer.hiddenItems; if (identical(now, _hiddenSeen)) return; _hiddenSeen = now; setState(() {}); for (final q in _ctl.panes) { q.changed(); } } // ───────── 창 보기 (E5d-3: ExplorerPaneView · ExplorerListView 가 부르는 것) ───────── @override double get rowHeight => _layout.rowHeight(); @override double get listTextScale => _layout.listTextScale; @override bool get narrowSplit => _layout.narrowSplit; @override bool get alwaysMarks => _layout.alwaysMarks; @override Widget frame(int i, List children) => ExplorerPaneFrame(ctl: _ctl, index: i, onPointer: _input.mouseTravel, onDrop: onDrop, children: children); @override Future<(int, int)?> diskSpace(String path) => widget.device.diskSpace(path); @override Widget header(ExplorerPane pane) => ExplorerPaneHeader( ctl: _ctl, servers: _servers, pane: pane, android: widget.device.androidPaths, 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) => _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) => _flows.drops.dragPaths(pane, e); @override Future onDrop(DragLoad load, int to, String dest) => _flows.drops.drop(context, load, to, dest); @override void closeFilter(ExplorerPane pane) => _input.closeFilter(pane); @override void filterChanged() => setState(() {}); /// 기능 버튼 → 할 일 (E5d-4) late final _buttons = ExplorerButtons(_ctl, _sel, this, _flows); /// 흐름 모듈 묶음 - 막기 · 진행 작업 · 복사 · 이동 · rsync · 지우기 · 열기 · 이름 · 도구 · 보기 창 · 찾기 (E5d-5 · E5d-6) late final ExplorerFlows _flows = ExplorerFlows(_ctl, _sel, androidTrash: () => widget.device.androidTrash, recycleBin: () => widget.device.recycleBin, 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 => _layout.other?.current; /// 줄 메뉴 (E5d-5) late final _itemMenu = ExplorerItemMenu(_ctl, _sel, this, _flows, nomedia: () => widget.device.nomedia); @override bool get dual => _layout.dual; /// 권한 · 시스템 폴더 · 다시 연결 · 목록 위 안내 (E5d-6) late final _access = ExplorerAccess(_ctl, probe: widget.device.fs.probe)..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) => _flows.opener.open(context, path, pane: pane)) ..addListener(_onPane); void _snack(String text) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } 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((_) => _layout.reveal(_ctl.pane, dir))); } // ───────── 화면 ───────── /// 49: 뒤로 키가 화면을 닫기 전에 할 일이 있는지 (고른 것이 있거나 맨 위 폴더가 아님) bool get _backHasWork => _sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty) || (_ctl.ready && !samePath(_ctl.pane.current, _ctl.pane.root)); /// 49: 뒤로 키 - 먼저 고른 것 풀기, 그다음 상위 폴더, 맨 위면 화면 닫기 (위쪽 ← 는 늘 닫기) Future _onBack() async { if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { _sel.setSelecting(false); return; } if (!samePath(_ctl.pane.current, _ctl.pane.root)) { await _buttons.press(ExplorerButton.up); return; } if (mounted) Navigator.of(context).pop(); } @override Widget build(BuildContext context) { return PopScope( canPop: !_backHasWork, onPopInvokedWithResult: (didPop, _) { if (!didPop) unawaited(_onBack()); }, child: _buildPage(context), ); } Widget _buildPage(BuildContext context) { // P0 (15 지적 10/17): 글자 칸 (빠른 필터 · 경로 등) 에 치는 중이면 파일 단축키를 쓰지 않는다 - 예전에는 CallbackShortcuts 가 // 글자 칸보다 먼저 키를 받아, 칸에서 Delete 를 누르면 파일 지우기 창이 뜨고 Ctrl+A · C · V · Space 도 파일에 쓰였다 final bindings = { // 83 · X-plore 비교 5-1 ⑥: 스타일에 맞는 처음 키 + 사용자가 바꾼 키 ([explorer_keys.dart], 환경 설정 > 파일 탐색기 > 단축키) ..._input.bindings(context), // 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) - Esc 는 바꾸지 않는다 const SingleActivator(LogicalKeyboardKey.escape): () { // 76: 열린 빠른 필터부터 닫는다 if (_ctl.panes[_ctl.active].filterOpen) { _input.closeFilter(_ctl.panes[_ctl.active]); } else if (_sel.selecting || _ctl.panes.any((x) => !x.sel.isEmpty)) { _sel.setSelecting(false); } }, }; return Focus( canRequestFocus: false, skipTraversal: true, onKeyEvent: (_, ev) => _input.shortcut(bindings, ev), child: Focus( focusNode: _input.focus, autofocus: true, // 76: 목록에서 글자를 바로 치면 지금 창의 빠른 필터가 그 글자로 열린다 (One Commander · TC 처럼) onKeyEvent: (_, ev) => _input.typeToFilter(ev), child: Listener( // 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [ExplorerPaneFrame]) onPointerDown: (ev) => _input.mouseTravel(_ctl.active, ev), child: Scaffold( body: SwipeNav( current: widget.rsync ? 'rsync' : 'explorer', child: ListenableBuilder( listenable: c, builder: (context, _) { _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: [ 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() : WifiShareBar(c: c, share: s), ), Expanded( child: Stack(children: [ // X-plore 비교 ⚖: 탐색기 안에서 밀기 = 창 바꾸기 (안쪽이 먼저 받아 화면 이동은 위 막대에서만) Positioned.fill( child: !_ctl.ready ? const Center(child: CircularProgressIndicator()) : _layout.swipePanes ? GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragEnd: (d) { final v = d.primaryVelocity ?? 0; if (v.abs() >= 500) _layout.switchPane(v < 0 ? 1 : -1); }, child: body) : body), // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 Positioned(left: 8, right: 8, bottom: 0, child: ExplorerTransferPanel(c: c, jobs: _flows.jobs.list, rsync: widget.rsync)), ]), ), ]); }, ), ), ), ), ), ); } }