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
parent
4b3ccb0c4f
commit
79f2a2151f
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('서버 설정 고치기'))),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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('끄기'))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
Loading…
Reference in New Issue