E5d-6: 권한 · 안내 · 보기 창 · 찾기 · 이름 · 도구 · 끌어 놓기 · 키 · 배치를 모듈로 (explorer_page 2,032 → 474)

- 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 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 13:48:49 +09:00
parent 4b3ccb0c4f
commit 79f2a2151f
20 changed files with 2289 additions and 1813 deletions

View File

@ -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<bool> Function(String path) probe;
/// 이번 실행에서 경고를 보였는지 (관리자 결정 10/21: 앱을 켠 뒤 처음 열 때 한 번)
static bool shizukuWarned = false, rootWarned = false;
@override
void dispose() {
if (allFiles != null) _life.dispose();
super.dispose();
}
Future<void> probeDrive(String path) => ctl.drives.check(path, probe);
/// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다
Future<void> 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<void> 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<void> openRoot(BuildContext context, ExplorerPane pane) async {
if (!rootWarned) {
final ok = await showDialog<bool>(
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<void> openShizuku(BuildContext context, ExplorerPane pane) async {
if (!shizukuWarned) {
final ok = await showDialog<bool>(
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));
}
}

View File

@ -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<String> dragPaths(ExplorerPane pane, FileEntry e) =>
sel.isMarked(pane, e.path) && !pane.sel.isEmpty ? outermostPaths(pane.sel.marked) : [e.path];
/// 82: 놓음 - 설정 "끌어 놓기 기본" (묻기 · 복사 · 이동) 뒤 지금의 복사 · 이동 (확인 창 · 같은 이름 · 진행 패널).
/// 이동이 끝나면 아래 알림에 [되돌리기] (10초)
Future<void> 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 = <String>{};
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<String> existed) {
// (원래 폴더 → 지금 경로들)
final back = <String, List<String>>{};
// 되돌리지 않는 것: 합친 폴더 (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<void> _undoMove(BuildContext context, ExplorerPane pane, Map<String, List<String>> 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);
}
}
}

View File

@ -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<String> ancestorsOf(ExplorerPane pane) {
final out = <String>[];
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<String>(
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<Object>(
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,
),
),
),
),
),
]),
);
}

View File

@ -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 = <Widget>[
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);
});
}
}

View File

@ -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<void> quickLook(ExplorerPane pane, [String? path]);
Future<void> addMkv(List<String> paths);
Future<void> renameItem(ExplorerPane pane, String path);
Future<void> batchRename(ExplorerPane pane, List<String> paths);
Future<void> openSearch(ExplorerPane pane);
Future<void> openStorage(ExplorerPane pane, String dir);
Future<void> openWifiShare(String dir);
Future<void> sortDialog();
Future<void> historyDialog(ExplorerPane pane);
Future<void> openBookmarks(ExplorerPane pane);
Future<void> toggleOrientation();
Future<void> layoutDialog();
Future<void> 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);
}
}
}

View File

@ -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();
}
}

View File

@ -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';
}

View File

@ -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':

View File

@ -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<ShortcutActivator, VoidCallback> bindings(BuildContext context) {
final cb = <String, VoidCallback>{
'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<void> _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<EditableTextState>() != null;
/// 탐색기 단축키: 글자 칸에 치는 중이면 넘기고 (칸의 기본 동작 - 선택 · 복사 · 붙여넣기 · 지우기 · 빈칸), 아니면 맞는 것을 한다
KeyEventResult shortcut(Map<ShortcutActivator, VoidCallback> 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;
}
}

View File

@ -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<void> addMkv(List<String> paths) async {
if (paths.any(isDav)) {
ctl.host.snack(tr('WebDAV · SFTP 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.'));
return;
}
final videos = <String>[];
final unreadable = <String>[];
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<void> 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<void>(
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)),
),
);
}
}

View File

@ -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<Widget?> 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<Widget> actions,
Color? color}) =>
_noticeBox(icon, title, body, actions, color ?? JjColors.alert);
Widget _noticeBox(IconData icon, String title, String body, List<Widget> 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<FileEntry> _partials(ExplorerPane pane, String dir) {
final old = DateTime.now().subtract(const Duration(minutes: 2));
return [
for (final e in pane.cache[dir] ?? const <FileEntry>[])
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<bool>(
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 = <String>[];
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('서버 설정 고치기'))),
],
);
}
}

View File

@ -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<void> Function(DragLoad load, int to, String dest) onDrop;
final List<Widget> 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<DragLoad>(
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),
),
),
),
);
}
}

View File

@ -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<void> 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<void> batch(BuildContext context, ExplorerPane pane, List<String> paths) async {
if (guard.blockArchive(paths)) return;
int at(String x) {
final i = (pane.cache[vDirname(x)] ?? const <FileEntry>[]).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<void> _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();
}
}
}

View File

@ -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<FileEntry> found, bool done, Map<String, String> moved})? v) {
_back = v;
notifyListeners();
}
/// 79: 마지막 찾기 조건 (이번 실행 동안 다음 찾기 창의 처음 값)
SearchConditions _last = const SearchConditions();
/// 79-a: 결과 화면에서 복사 · 이동 · 지우기 · [그 자리로] 를 한 뒤 [찾기 결과로 돌아가기] 로 다시 열 결과 (다음 찾기 · ✕ · 결과 화면을 그냥 닫으면 없어짐)
({ExplorerPane pane, String root, SearchConditions cond, List<FileEntry> found, bool done, Map<String, String> moved})? _back;
/// 79: 찾기 - 이름 · 크기 · 바뀐 날 · 종류 조건 → 결과 목록 화면 (고르고 그대로 복사 · 이동 · 지우기, 누르면 열기 · 재생)
Future<void> 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<void> backToResults(BuildContext context) async {
final back = _back;
if (back == null) return;
final now = <FileEntry>[];
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<void> _page(BuildContext context, ExplorerPane pane, String root, SearchConditions cond, {List<FileEntry>? initial, bool initialDone = true}) async {
final other = otherDir();
final act = await Navigator.of(context).push<SearchAction>(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 <String, String>{},
));
}
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),
),
]),
),
);
}
}

View File

@ -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<void> openStorage(BuildContext context, ExplorerPane pane, String dir) async {
ctl
..active = ctl.panes.indexOf(pane)
..update();
if (isDav(dir)) {
final ok = await showDialog<bool>(
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<void> 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('끄기'))),
]),
),
),
);
}
}

View File

@ -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<void> sortDialog(BuildContext context) async {
// 84: "폴더마다 기억" 이면 지금 창의 폴더 정렬
final dir = ctl.pane.current;
var (by, desc) = ctl.sortOf(dir);
final ok = await showDialog<bool>(
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<void> 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<void> historyDialog(BuildContext context, ExplorerPane pane) async {
final hist = ctl.c.settings.explorerHistory.where(reachable).toList();
final pick = await showDialog<String>(
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<void> 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<void> layoutDialog(BuildContext context) async {
final s = ctl.c.settings;
var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar;
final ok = await showDialog<bool>(
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<void> 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<bool>(
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);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<void> quickLook(ExplorerPane pane, [String? path]) => _no();
Never _no() => throw UnimplementedError('이 시험에서 부르지 않음');
@override
Future<void> addMkv(List<String> paths) => _no();
@override
Future<void> renameItem(ExplorerPane pane, String path) => _no();
@override
Future<void> batchRename(ExplorerPane pane, List<String> paths) => _no();
@override
Future<void> openSearch(ExplorerPane pane) => _no();
@override
Future<void> openStorage(ExplorerPane pane, String dir) => _no();
@override
Future<void> openWifiShare(String dir) => _no();
@override
Future<void> sortDialog() => _no();
@override
Future<void> historyDialog(ExplorerPane pane) => _no();
@override
Future<void> openBookmarks(ExplorerPane pane) => _no();
@override
Future<void> toggleOrientation() => _no();
@override
Future<void> layoutDialog() => _no();
@override
Future<void> buttonsDialog() => _no();
@override
Widget build(BuildContext context) => Scaffold(body: child?.call() ?? const SizedBox());

View File

@ -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<void> quickLook(ExplorerPane pane, [String? path]) => _no();
@override
Future<void> addMkv(List<String> paths) => _no();
@override
Future<void> renameItem(ExplorerPane pane, String path) => _no();
@override
Future<void> batchRename(ExplorerPane pane, List<String> paths) => _no();
@override
Future<void> openSearch(ExplorerPane pane) => _no();
@override
Future<void> openStorage(ExplorerPane pane, String dir) => _no();
@override
Future<void> openWifiShare(String dir) => _no();
@override
Future<void> sortDialog() => _no();
@override
Future<void> historyDialog(ExplorerPane pane) => _no();
@override
Future<void> openBookmarks(ExplorerPane pane) => _no();
@override
Future<void> toggleOrientation() => _no();
@override
Future<void> layoutDialog() => _no();
@override
Future<void> buttonsDialog() => _no();
@override
Widget build(BuildContext context) => const Scaffold(body: SizedBox());

View File

@ -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<void> settle(WidgetTester t, [bool Function()? until]) async {
for (var i = 0; i < 30 && !(until?.call() ?? false); i++) {
await t.runAsync(() => Future<void>.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<Widget>().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());
}