452 lines
20 KiB
Dart
452 lines
20 KiB
Dart
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<void> open(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: false);
|
|
|
|
/// Rsync 화면으로 (이미 열려 있으면 그 화면으로)
|
|
static Future<void> openRsync(NavigatorState nav, {required AppController c}) => _show(nav, c, rsync: true);
|
|
|
|
/// 162: 이 앱의 파일 탐색기로 그 폴더를 연다 (이미 열려 있으면 그 화면에서 그 폴더로). 끝난 작업의 [열기] 등
|
|
static Future<void> openAt(NavigatorState nav, {required AppController c, required String dir}) {
|
|
goToRequest.value = dir;
|
|
return _show(nav, c, rsync: false);
|
|
}
|
|
|
|
/// 열려 있는 (또는 곧 열릴) 탐색기가 갈 폴더 (가면 비운다)
|
|
static final goToRequest = ValueNotifier<String?>(null);
|
|
|
|
static Future<void> _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<void>(
|
|
settings: RouteSettings(name: name),
|
|
builder: (_) => ExplorerPage(c: c, rsync: rsync),
|
|
));
|
|
}
|
|
|
|
@override
|
|
State<ExplorerPage> createState() => _ExplorerPageState();
|
|
}
|
|
|
|
class _ExplorerPageState extends State<ExplorerPage> 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<void> _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<String>? _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<Widget> 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<Widget?> 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<String> dragPaths(ExplorerPane pane, FileEntry e) => _flows.drops.dragPaths(pane, e);
|
|
@override
|
|
Future<void> 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<void> _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 = <ShortcutActivator, VoidCallback>{
|
|
// 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<WifiShare?>(
|
|
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)),
|
|
]),
|
|
),
|
|
]);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|